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
+3 -3
View File
@@ -14,7 +14,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/version-v0.14.12-E8734A?style=flat-square" alt="version"> <img src="https://img.shields.io/badge/version-v0.14.13-E8734A?style=flat-square" alt="version">
<img src="https://img.shields.io/badge/electron-33+-47848F?style=flat-square&logo=electron" alt="electron"> <img src="https://img.shields.io/badge/electron-33+-47848F?style=flat-square&logo=electron" alt="electron">
<img src="https://img.shields.io/badge/typescript-5.7+-3178C6?style=flat-square&logo=typescript" alt="typescript"> <img src="https://img.shields.io/badge/typescript-5.7+-3178C6?style=flat-square&logo=typescript" alt="typescript">
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="license"> <img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="license">
@@ -253,7 +253,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist 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 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 ## 🛠️ Common Commands
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "0.14.12", "version": "0.14.13",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "0.14.12", "version": "0.14.13",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ffmpeg-static": "^5.2.0", "ffmpeg-static": "^5.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "0.14.12", "version": "0.14.13",
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端", "description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
"main": "dist/main/main.js", "main": "dist/main/main.js",
"author": "thzxx", "author": "thzxx",
+1 -1
View File
@@ -101,7 +101,7 @@ export function createMenu(): void {
dialog.showMessageBox(mainWindow!, { dialog.showMessageBox(mainWindow!, {
type: 'info', type: 'info',
title: '关于 Metona Ollama', 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', detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
icon: getIconPath() icon: getIconPath()
}); });
+10 -33
View File
@@ -530,7 +530,7 @@ async function handleRetry(): Promise<void> {
result: null, status: 'pending', timestamp: Date.now() result: null, status: 'pending', timestamp: Date.now()
}); });
}, },
onNewIteration: (toolCalls) => { onNewIteration: (toolCalls, stats) => {
removeCurrentPlaceholder(); removeCurrentPlaceholder();
const hasContent = !!retryContent?.trim(); const hasContent = !!retryContent?.trim();
if (hasContent || retryIterations === 0) { if (hasContent || retryIterations === 0) {
@@ -540,6 +540,10 @@ async function handleRetry(): Promise<void> {
timestamp: now, timestamp: now,
...(retryThinkContent && { think: retryThinkContent }), ...(retryThinkContent && { think: retryThinkContent }),
...(retryIterationToolRecords.length > 0 && { toolCalls: [...retryIterationToolRecords] }), ...(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 = []; retryIterationToolRecords = [];
state.update(KEYS.CURRENT_SESSION, (s: any) => ({ state.update(KEYS.CURRENT_SESSION, (s: any) => ({
@@ -635,7 +639,6 @@ async function handleRetry(): Promise<void> {
})); }));
} }
await saveCurrentSession(); await saveCurrentSession();
if (loopStats?.ctx_tokens) updateCtxRemain(loopStats.ctx_tokens);
} }
}); });
} catch (err) { } catch (err) {
@@ -798,34 +801,6 @@ function stopGeneration(): void {
if (abortController) abortController.abort(); 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<number>(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 预算比例:文件内容最多占用上下文窗口的比例 */ /** Token 预算比例:文件内容最多占用上下文窗口的比例 */
const FILE_TOKEN_BUDGET_RATIO = 0.3; 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() result: null, status: 'pending', timestamp: Date.now()
}); });
}, },
onNewIteration: (toolCalls) => { onNewIteration: (toolCalls, stats) => {
// 保存上一轮的卡片(含工具记录) // 保存上一轮的卡片(含工具记录)
const prevMsg: ChatMessage = { const prevMsg: ChatMessage = {
role: 'assistant', role: 'assistant',
@@ -1273,6 +1248,10 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
timestamp: Date.now(), timestamp: Date.now(),
...(thinkContent && { think: thinkContent }), ...(thinkContent && { think: thinkContent }),
...(currentIterationToolRecords.length > 0 && { toolCalls: [...currentIterationToolRecords] }), ...(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 = []; currentIterationToolRecords = [];
state.update(KEYS.CURRENT_SESSION, (session: any) => ({ state.update(KEYS.CURRENT_SESSION, (session: any) => ({
@@ -1372,8 +1351,6 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
})); }));
} }
await saveCurrentSession(); await saveCurrentSession();
// 更新剩余上下文 — P0 修复:应使用 ctx_tokens(总上下文占用估算)而非 prompt_eval_count(仅最后一轮输入 token
if (loopStats?.ctx_tokens) updateCtxRemain(loopStats.ctx_tokens);
} }
}); });
} catch (err) { } catch (err) {
+19 -13
View File
@@ -8,7 +8,6 @@ import { formatSize } from '../utils/utils.js';
import { OllamaAPI } from '../api/ollama.js'; import { OllamaAPI } from '../api/ollama.js';
import { ChatDB } from '../db/chat-db.js'; import { ChatDB } from '../db/chat-db.js';
import { logModel, logDebug, logError, logWarn } from '../services/log-service.js'; import { logModel, logDebug, logError, logWarn } from '../services/log-service.js';
import { clearCtxRemain } from './input-area.js';
import type { OllamaModelDetail, ModelCaps } from '../types.js'; import type { OllamaModelDetail, ModelCaps } from '../types.js';
let modelSelectEl: HTMLSelectElement; let modelSelectEl: HTMLSelectElement;
@@ -170,9 +169,6 @@ async function filterEmbedModels(models: Array<{ name: string }>): Promise<void>
} }
async function checkModelCapability(modelName: string): Promise<void> { async function checkModelCapability(modelName: string): Promise<void> {
// 切换模型时清除旧上下文剩余显示
clearCtxRemain();
if (modelCapabilityCache.has(modelName)) { if (modelCapabilityCache.has(modelName)) {
const cached = modelCapabilityCache.get(modelName)!; const cached = modelCapabilityCache.get(modelName)!;
applyCapability(modelName, cached); applyCapability(modelName, cached);
@@ -294,15 +290,25 @@ export function formatCtxLen(n: number): string {
return String(n); return String(n);
} }
/** 更新模型栏上下文长度显示(显示用户配置的值,而非模型自身值) */ /** 获取当前选中模型自身支持的上下文长度 */
function updateCtxTotal(contextLength: number): void { export function getModelContextLength(): number {
const el = document.querySelector('#ctxTotal') as HTMLElement; const model = modelSelectEl?.value;
if (!el) return; if (!model) return 0;
el.style.display = 'inline-flex'; const caps = modelCapabilityCache.get(model);
el.textContent = formatCtxLen(contextLength); return caps?.contextLength || 0;
} }
/** 外部调用:更新上下文长度显示 */ /** 获取当前选中模型的名称 */
export function updateCtxTotalExternal(contextLength: number): void { export function getCurrentModelName(): string {
updateCtxTotal(contextLength); return modelSelectEl?.value || '';
}
/** 获取有效的上下文长度:min(模型支持值, 用户设置值) */
export function getEffectiveNumCtx(): number {
const userCtx = state.get<number>(KEYS.NUM_CTX, 131072);
const modelCtx = getModelContextLength();
if (modelCtx > 0 && modelCtx < userCtx) {
return modelCtx;
}
return userCtx;
} }
+1 -3
View File
@@ -7,7 +7,7 @@ import { debounce } from '../utils/utils.js';
import { encryptData, decryptData } from '../services/crypto.js'; import { encryptData, decryptData } from '../services/crypto.js';
import { logSetting, logInfo, logWarn, logError, logSuccess } from '../services/log-service.js'; import { logSetting, logInfo, logWarn, logError, logSuccess } from '../services/log-service.js';
import { checkConnection, updateConnectionInfo, updateRunningModels } from './header.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 { showToast } from './toast.js';
import { showConfirm } from './prompt-modal.js'; import { showConfirm } from './prompt-modal.js';
import { OllamaAPI } from '../api/ollama.js'; import { OllamaAPI } from '../api/ollama.js';
@@ -71,8 +71,6 @@ export function initSettingsModal(): void {
if (!val) return; if (!val) return;
state.set(KEYS.NUM_CTX, val); state.set(KEYS.NUM_CTX, val);
if (db) await db.saveSetting('numCtx', val); if (db) await db.saveSetting('numCtx', val);
// 更新模型栏显示
updateCtxTotalExternal(val);
logSetting('上下文长度', formatCtxLen(val)); logSetting('上下文长度', formatCtxLen(val));
}); });
+219 -28
View File
@@ -5,7 +5,9 @@
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 { 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'; import type { ChatSession, ChatMessage } from '../types.js';
let dashboardModalEl: HTMLElement; let dashboardModalEl: HTMLElement;
@@ -37,14 +39,21 @@ interface AllSessionsTokenStats {
totals: GlobalTokenTotals; totals: GlobalTokenTotals;
} }
let globalStatsCache: AllSessionsTokenStats | null = null; /** P1 #5: HTML 转义,防止 XSS */
let globalStatsLoaded = false; function escapeHtml(s: unknown): string {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/** 打开仪表盘 */ /** 打开仪表盘 */
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();
} }
@@ -78,7 +87,6 @@ export function initTokenDashboard(): void {
toggleEl.querySelectorAll('.td-view-btn').forEach(btn => btn.classList.remove('active')); toggleEl.querySelectorAll('.td-view-btn').forEach(btn => btn.classList.remove('active'));
target.classList.add('active'); target.classList.add('active');
// 切换时刷新 // 切换时刷新
if (viewMode === 'global') globalStatsLoaded = false;
renderDashboard(); renderDashboard();
} }
}); });
@@ -143,16 +151,19 @@ function formatTokenCount(n: number): string {
return String(n); return String(n);
} }
/** 获取全局统计数据(带缓存 */ /** P1 #3: 上次成功获取全局数据(用于刷新期间保持旧数据可见,避免闪烁 */
let lastSuccessfulStats: AllSessionsTokenStats | null = null;
let hasEverLoaded = false;
/** P1 #3: 获取全局统计数据 — 每次都重新查询,不做永久缓存 */
async function fetchGlobalStats(): Promise<AllSessionsTokenStats | null> { async function fetchGlobalStats(): Promise<AllSessionsTokenStats | null> {
if (globalStatsLoaded && globalStatsCache) return globalStatsCache;
try { try {
const desktop = window.metonaDesktop; const desktop = window.metonaDesktop;
if (!desktop?.db?.getAllTokenStats) return null; if (!desktop?.db?.getAllTokenStats) return null;
const result = await desktop.db.getAllTokenStats(); const result = await desktop.db.getAllTokenStats();
if (result) { if (result) {
globalStatsCache = result; lastSuccessfulStats = result;
globalStatsLoaded = true; hasEverLoaded = true;
return result; return result;
} }
} catch (err) { } catch (err) {
@@ -162,6 +173,10 @@ async function fetchGlobalStats(): Promise<AllSessionsTokenStats | null> {
} }
function renderDashboard(): void { function renderDashboard(): void {
// 全局视图时隐藏上下文环形图区域(仅会话视图显示)
const ringSection = dashboardModalEl.querySelector('#tdCtxRingSection') as HTMLElement | null;
if (ringSection) ringSection.style.display = viewMode === 'session' ? '' : 'none';
if (viewMode === 'global') { if (viewMode === 'global') {
renderGlobalView(); renderGlobalView();
} else { } else {
@@ -173,7 +188,14 @@ function renderDashboard(): void {
function renderGlobalView(): void { function renderGlobalView(): void {
fetchGlobalStats().then(stats => { fetchGlobalStats().then(stats => {
if (!stats) { if (!stats) {
renderGlobalFallback(); // P1 #3/#9: 刷新失败时保留旧数据,仅在从未加载成功时显示错误
if (hasEverLoaded && lastSuccessfulStats) {
renderGlobalOverview(lastSuccessfulStats);
renderGlobalChart(lastSuccessfulStats);
renderGlobalTable(lastSuccessfulStats);
} else {
renderGlobalFallback(true);
}
return; return;
} }
renderGlobalOverview(stats); renderGlobalOverview(stats);
@@ -183,19 +205,20 @@ function renderGlobalView(): void {
} }
/** 全局统计获取失败时的降级处理 */ /** 全局统计获取失败时的降级处理 */
function renderGlobalFallback(): void { function renderGlobalFallback(isError = false): void {
const overviewEl = dashboardModalEl.querySelector('#tdOverview')!; const overviewEl = dashboardModalEl.querySelector('#tdOverview')!;
const msg = isError ? '全局统计加载失败,正在重试…' : '正在加载全局数据…';
overviewEl.innerHTML = ` overviewEl.innerHTML = `
<div class="td-stat-card"> <div class="td-stat-card">
<div class="td-stat-icon">🌍</div> <div class="td-stat-icon">🌍</div>
<div class="td-stat-value">-</div> <div class="td-stat-value">-</div>
<div class="td-stat-label">全局统计加载中…</div> <div class="td-stat-label">${msg}</div>
</div> </div>
`; `;
const chartEl = dashboardModalEl.querySelector('#tdChart')!; 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')!; 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( function renderBarChart(
chartEl: HTMLElement, chartEl: HTMLElement,
data: Array<{ label: string; input: number; output: number; tooltip: string }>, data: Array<{ label: string; input: number; output: number; tooltip: string }>,
title: string,
note?: string, note?: string,
savedScrollLeft?: number savedScrollLeft?: number
): void { ): void {
@@ -264,7 +286,7 @@ function renderBarChart(
const inputPct = total > 0 ? (d.input / total) * 100 : 0; const inputPct = total > 0 ? (d.input / total) * 100 : 0;
const outputPct = 100 - inputPct; const outputPct = 100 - inputPct;
return ` 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-area">
<div class="td-bar-stack" style="height:${heightPct}%;"> <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-output" style="height:${outputPct}%;"></div>
@@ -275,7 +297,7 @@ function renderBarChart(
<span class="td-val-output">${formatTokenCount(d.output)}</span> <span class="td-val-output">${formatTokenCount(d.output)}</span>
<span class="td-val-input">${formatTokenCount(d.input)}</span> <span class="td-val-input">${formatTokenCount(d.input)}</span>
</div> </div>
<div class="td-bar-label">${d.label}</div> <div class="td-bar-label">${escapeHtml(d.label)}</div>
</div> </div>
`; `;
}).join(''); }).join('');
@@ -284,7 +306,7 @@ function renderBarChart(
<div class="td-chart-legend"> <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-output"></span>输出 Token</span>
<span class="td-legend-item"><span class="td-legend-dot td-legend-input"></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>
<div class="td-chart-grid"> <div class="td-chart-grid">
${gridLines} ${gridLines}
@@ -314,7 +336,7 @@ function renderGlobalChart(stats: AllSessionsTokenStats): void {
return; return;
} }
// 按创建时间正序排列,取最近 30 个会话 // P2 #10: 统一排序 — 按 created_at 正序(图表左→右为时间递增)
const sorted = [...sessions].sort((a, b) => a.created_at - b.created_at).slice(-30); const sorted = [...sessions].sort((a, b) => a.created_at - b.created_at).slice(-30);
const chartData = sorted.map(s => ({ 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}`, 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'); 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 tableEl = dashboardModalEl.querySelector('#tdTableBody')!;
const sessions = stats.sessions; const sessions = stats.sessions;
@@ -343,12 +369,24 @@ function renderGlobalTable(stats: AllSessionsTokenStats): void {
return; 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; const total = s.total_input + s.total_output;
return ` return `
<tr> <tr>
<td title="${s.title}">${s.title.length > 20 ? s.title.slice(0, 20) + '…' : s.title}</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">${s.model}</span></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_input)}</td>
<td class="td-num">${formatTokenCount(s.total_output)}</td> <td class="td-num">${formatTokenCount(s.total_output)}</td>
<td class="td-num td-num-total">${formatTokenCount(total)}</td> <td class="td-num td-num-total">${formatTokenCount(total)}</td>
@@ -357,12 +395,141 @@ function renderGlobalTable(stats: AllSessionsTokenStats): void {
</tr> </tr>
`; `;
}).join(''); }).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 { 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 || [];
const isStreaming = state.get<boolean>(KEYS.IS_STREAMING, false);
const userMsgs = messages.filter(m => m.role === 'user'); const userMsgs = messages.filter(m => m.role === 'user');
const aiMsgs = messages.filter(m => m.role === 'assistant'); 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 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;
// 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')!; const overviewEl = dashboardModalEl.querySelector('#tdOverview')!;
overviewEl.innerHTML = ` overviewEl.innerHTML = `
@@ -400,7 +574,7 @@ function renderSessionView(): void {
</div> </div>
<div class="td-stat-card td-stat-card-small"> <div class="td-stat-card td-stat-card-small">
<div class="td-stat-icon">🤖</div> <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 class="td-stat-label">当前模型</div>
</div> </div>
`; `;
@@ -420,19 +594,30 @@ function renderSessionView(): void {
const chartTitleEl = dashboardModalEl.querySelector('#tdChartTitle'); const chartTitleEl = dashboardModalEl.querySelector('#tdChartTitle');
if (chartTitleEl) chartTitleEl.textContent = '📈 Token 消耗趋势 — 按轮次'; if (chartTitleEl) chartTitleEl.textContent = '📈 Token 消耗趋势 — 按轮次';
renderBarChart(chartEl, chartData, '按轮次', undefined, savedScrollLeft); renderBarChart(chartEl, chartData, undefined, savedScrollLeft);
// 表格标题 // 表格标题
const tableTitleEl = dashboardModalEl.querySelector('#tdTableTitle') as HTMLElement | null; const tableTitleEl = dashboardModalEl.querySelector('#tdTableTitle') as HTMLElement | null;
if (tableTitleEl) tableTitleEl.textContent = '📋 轮次明细(按时间倒序)'; // P1 #6: 流式输出期间显示提示
if (tableTitleEl) {
tableTitleEl.textContent = isStreaming
? '📋 轮次明细(按时间倒序)— ⚡ AI 回复中,数据实时更新…'
: '📋 轮次明细(按时间倒序)';
}
// 动态更新表头 // 动态更新表头
updateTableHeader('session'); updateTableHeader('session');
// P1 #4: 保存表格滚动位置
const tableWrapEl = dashboardModalEl.querySelector('.td-table-wrap') as HTMLElement | null;
const savedScrollTop = tableWrapEl?.scrollTop ?? 0;
// 明细表格 // 明细表格
const tableEl = dashboardModalEl.querySelector('#tdTableBody')!; const tableEl = dashboardModalEl.querySelector('#tdTableBody')!;
if (rounds.length === 0) { 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 { } else {
const sorted = [...rounds].reverse(); const sorted = [...rounds].reverse();
tableEl.innerHTML = sorted.map(r => { tableEl.innerHTML = sorted.map(r => {
@@ -440,7 +625,7 @@ function renderSessionView(): void {
return ` return `
<tr> <tr>
<td>R${r.round}</td> <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.prompt_eval_count)}</td>
<td class="td-num">${formatTokenCount(r.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 td-num-total">${formatTokenCount(total)}</td>
@@ -450,6 +635,12 @@ function renderSessionView(): void {
`; `;
}).join(''); }).join('');
} }
// P1 #4: 恢复表格滚动位置
if (savedScrollTop > 0) {
const newWrapEl = dashboardModalEl.querySelector('.td-table-wrap') as HTMLElement | null;
if (newWrapEl) newWrapEl.scrollTop = savedScrollTop;
}
} }
/** 动态更新表头列 */ /** 动态更新表头列 */
+5 -3
View File
@@ -28,7 +28,7 @@
<div class="header-left"> <div class="header-left">
<img class="logo" src="./assets/icons/llama.png" alt="logo" /> <img class="logo" src="./assets/icons/llama.png" alt="logo" />
<span class="app-title">Metona Ollama</span> <span class="app-title">Metona Ollama</span>
<span class="app-version">v0.14.12</span> <span class="app-version">v0.14.13</span>
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助"> <button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/> <circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
@@ -102,8 +102,6 @@
<span class="model-badge vision-badge" id="badgeVision" style="display:none;">👁️ Vision</span> <span class="model-badge vision-badge" id="badgeVision" style="display:none;">👁️ Vision</span>
<span class="model-badge tools-badge" id="badgeTools" style="display:none;">🔧 Tools</span> <span class="model-badge tools-badge" id="badgeTools" style="display:none;">🔧 Tools</span>
</div> </div>
<span class="ctx-total" id="ctxTotal" title="模型上下文窗口大小"></span>
<span class="ctx-remain" id="ctxRemain" style="display:none;" title="剩余可用上下文"></span>
</div> </div>
<!-- ═══════════════ 聊天区域 ═══════════════ --> <!-- ═══════════════ 聊天区域 ═══════════════ -->
@@ -732,6 +730,10 @@
<button class="td-view-btn active" data-view="global">🌍 全局统计</button> <button class="td-view-btn active" data-view="global">🌍 全局统计</button>
<button class="td-view-btn" data-view="session">💬 当前会话</button> <button class="td-view-btn" data-view="session">💬 当前会话</button>
</div> </div>
<div class="td-ctx-ring-section" id="tdCtxRingSection" style="display:none;">
<div class="td-section-title">🎯 上下文窗口使用率</div>
<div class="td-ctx-ring-wrap" id="tdCtxRing"></div>
</div>
<div class="td-section"> <div class="td-section">
<div class="td-section-title" id="tdChartTitle">📈 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>
+1 -7
View File
@@ -14,10 +14,9 @@ import { generateId } from './utils/utils.js';
import { initToast, showToast } from './components/toast.js'; import { initToast, showToast } from './components/toast.js';
import { initLightbox, closeLightbox } from './components/lightbox.js'; import { initLightbox, closeLightbox } from './components/lightbox.js';
import { initHeader, checkConnection } from './components/header.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 { initChatArea, renderMessages, clearMessages, enableAutoScroll } from './components/chat-area.js';
import { initInputArea } from './components/input-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 { initSettingsModal, closeSettingsModal } from './components/settings-modal.js';
import { initHistoryModal, closeHistoryModal } from './components/history-modal.js'; import { initHistoryModal, closeHistoryModal } from './components/history-modal.js';
import { initMemoryModal } from './components/memory-modal.js'; import { initMemoryModal } from './components/memory-modal.js';
@@ -257,9 +256,6 @@ async function startNewSession(): Promise<void> {
state.set(KEYS.CURRENT_SESSION, session); state.set(KEYS.CURRENT_SESSION, session);
logInfo('新建会话'); logInfo('新建会话');
// 清除剩余上下文显示
clearCtxRemain();
// ── 强制中断正在进行的生成 ── // ── 强制中断正在进行的生成 ──
const abortController = state.get<AbortController | null>(KEYS.ABORT_CONTROLLER); const abortController = state.get<AbortController | null>(KEYS.ABORT_CONTROLLER);
if (abortController) { if (abortController) {
@@ -388,8 +384,6 @@ async function init(): Promise<void> {
// 反显设置面板的下拉框 // 反显设置面板的下拉框
const ctxSelect = document.querySelector('#selectContextLength') as HTMLSelectElement; const ctxSelect = document.querySelector('#selectContextLength') as HTMLSelectElement;
if (ctxSelect) ctxSelect.value = String(numCtx); if (ctxSelect) ctxSelect.value = String(numCtx);
// 更新模型栏上下文显示
updateCtxTotalExternal(numCtx);
(document.querySelector('#inputTemperature') as HTMLInputElement).value = String(temperature); (document.querySelector('#inputTemperature') as HTMLInputElement).value = String(temperature);
document.querySelector('#tempValue')!.textContent = parseFloat(String(temperature)).toFixed(1); document.querySelector('#tempValue')!.textContent = parseFloat(String(temperature)).toFixed(1);
+38 -13
View File
@@ -25,6 +25,7 @@ import { buildContext, estimateTokens, shouldAutoCompress, compressWithLLM, AUTO
import { runCompletionGate } from './completion-gate.js'; import { runCompletionGate } from './completion-gate.js';
import { executeHooks } from './hooks.js'; import { executeHooks } from './hooks.js';
import { recordIteration, recordToolCall, recordCompletionGate, startSessionMetrics, endSessionMetrics } from './agent-metrics.js'; import { recordIteration, recordToolCall, recordCompletionGate, startSessionMetrics, endSessionMetrics } from './agent-metrics.js';
import { getEffectiveNumCtx } from '../components/model-bar.js';
import type { import type {
OllamaMessage, OllamaMessage,
OllamaStreamChunk, OllamaStreamChunk,
@@ -607,7 +608,7 @@ export interface AgentCallbacks {
onToolCallError: (name: string, error: string, call: ToolCall) => void; 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; onDone: (finalContent: string, toolRecords?: ToolCallRecord[], stats?: { eval_count?: number; prompt_eval_count?: number; total_duration?: number; ctx_tokens?: number }) => void;
onConfirmTool: (call: ToolCall) => Promise<boolean>; onConfirmTool: (call: ToolCall) => Promise<boolean>;
onNewIteration?: (toolCalls?: ToolCall[]) => void; onNewIteration?: (toolCalls?: ToolCall[], stats?: { eval_count?: number; prompt_eval_count?: number; total_duration?: number }) => void;
/** Plan Mode: 计划已生成,等待用户确认 */ /** Plan Mode: 计划已生成,等待用户确认 */
onPlanReady?: (plan: string, steps: string[]) => Promise<boolean>; onPlanReady?: (plan: string, steps: string[]) => Promise<boolean>;
} }
@@ -937,14 +938,16 @@ async function handleInit(
ctx.messages.push(userMsg); ctx.messages.push(userMsg);
// 上下文窗口管理:滑动窗口 + 摘要压缩 + token 裁剪 // 上下文窗口管理:滑动窗口 + 摘要压缩 + token 裁剪
const numCtx = state.get<number>(KEYS.NUM_CTX, 131072); // 钳制:取 min(模型支持值, 用户设置值),防止手动设置超过模型能力
const numCtx = getEffectiveNumCtx();
const contextResult = buildContext(ctx.messages, { maxTokens: numCtx, windowSize: 20 }); const contextResult = buildContext(ctx.messages, { maxTokens: numCtx, windowSize: 20 });
ctx.messages.length = 0; ctx.messages.length = 0;
ctx.messages.push(...contextResult); ctx.messages.push(...contextResult);
// 自动压缩检测 // 钳制:取 min(模型支持值, 用户设置值)
if (shouldAutoCompress(ctx.messages, numCtx)) { const effectiveNumCtx = getEffectiveNumCtx();
logInfo(`自动上下文压缩触发: tokens≈${estimateTokens(ctx.messages.map(m => m.content || '').join(''))} > ${Math.floor(numCtx * AUTO_COMPRESS_THRESHOLD)} (50% of ${numCtx})`); 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<AbortController | null>(KEYS.ABORT_CONTROLLER) || new AbortController(); const compressAC = state.get<AbortController | null>(KEYS.ABORT_CONTROLLER) || new AbortController();
try { try {
const compressed = await compressWithLLM(ctx.messages, api as any, model, { abortController: compressAC }); const compressed = await compressWithLLM(ctx.messages, api as any, model, { abortController: compressAC });
@@ -1337,7 +1340,7 @@ async function handleThinking(
stream: true, stream: true,
think: state.get<boolean>('thinkEnabled', false), think: state.get<boolean>('thinkEnabled', false),
options: { options: {
num_ctx: state.get<number>(KEYS.NUM_CTX, 131072), num_ctx: getEffectiveNumCtx(),
temperature: state.get<number>('temperature', 0.7) temperature: state.get<number>('temperature', 0.7)
}, },
...(getEnabledToolDefinitions().length > 0 && { tools: getEnabledToolDefinitions() }) ...(getEnabledToolDefinitions().length > 0 && { tools: getEnabledToolDefinitions() })
@@ -1394,6 +1397,13 @@ async function handleThinking(
abortController abortController
); );
// P0 修复:保存本轮独立 stats(供 onNewIteration / makeStats 使用,避免累计值导致重复计算)
ctx.lastLoopStats = {
eval_count: ctx.loopEvalCount || undefined,
prompt_eval_count: ctx.loopPromptEvalCount || undefined,
total_duration: ctx.loopInferenceNs || undefined,
};
// 累加 token 统计 // 累加 token 统计
ctx.totalEvalCount += ctx.loopEvalCount; ctx.totalEvalCount += ctx.loopEvalCount;
ctx.totalPromptEvalCount += ctx.loopPromptEvalCount; ctx.totalPromptEvalCount += ctx.loopPromptEvalCount;
@@ -2026,7 +2036,7 @@ async function handleCompressing(
api: OllamaAPI, api: OllamaAPI,
model: string, model: string,
): Promise<void> { ): Promise<void> {
const numCtx = state.get<number>(KEYS.NUM_CTX, 131072); const numCtx = getEffectiveNumCtx();
if (shouldAutoCompress(ctx.messages, numCtx)) { if (shouldAutoCompress(ctx.messages, numCtx)) {
logInfo('COMPRESSING: 上下文压缩触发'); logInfo('COMPRESSING: 上下文压缩触发');
const compressAC = state.get<AbortController | null>(KEYS.ABORT_CONTROLLER) || new AbortController(); const compressAC = state.get<AbortController | null>(KEYS.ABORT_CONTROLLER) || new AbortController();
@@ -2050,11 +2060,25 @@ async function handleCompressing(
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
function makeStats(ctx: LoopContext) { 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 { return {
eval_count: ctx.totalEvalCount || undefined, eval_count: ctx.lastLoopStats?.eval_count || undefined,
prompt_eval_count: ctx.totalPromptEvalCount || undefined, prompt_eval_count: ctx.lastLoopStats?.prompt_eval_count || undefined,
total_duration: ctx.totalInferenceNs || undefined, total_duration: ctx.lastLoopStats?.total_duration || undefined,
ctx_tokens: estimateTokens(ctx.messages.map(m => m.content || '').join('')), // 实际上下文占用 ctx_tokens: ctxTokens,
}; };
} }
@@ -2097,6 +2121,7 @@ export async function runAgentLoop(
mode, mode,
planRetries: 0, planRetries: 0,
startTime: Date.now(), startTime: Date.now(),
lastLoopStats: undefined,
}; };
state.set('_loopState', S.INIT); state.set('_loopState', S.INIT);
@@ -2148,7 +2173,7 @@ export async function runAgentLoop(
} }
// Token 感知的动态迭代预算 // Token 感知的动态迭代预算
const numCtx = state.get<number>(KEYS.NUM_CTX, 131072); const numCtx = getEffectiveNumCtx();
const usageRatio = estimateTokens(ctx.messages.map(m => m.content || '').join('')) / numCtx; const usageRatio = estimateTokens(ctx.messages.map(m => m.content || '').join('')) / numCtx;
// P2-2: 上下文健康度软收敛 — 60% 时提前预警,让 AI 主动收敛 // P2-2: 上下文健康度软收敛 — 60% 时提前预警,让 AI 主动收敛
@@ -2170,7 +2195,7 @@ export async function runAgentLoop(
// 仅在 THINKING 阶段通知 UI(一次迭代只通知一次,不是每个状态切换都通知) // 仅在 THINKING 阶段通知 UI(一次迭代只通知一次,不是每个状态切换都通知)
if (ctx.loopCount > 0 && ctx.state === S.THINKING && callbacks.onNewIteration) { 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);
} }
// 状态分发 // 状态分发
+4 -3
View File
@@ -28,12 +28,13 @@ export function recordActualTokens(actualInputTokens: number, actualOutputTokens
// C8: 模型切换时重置校准 // C8: 模型切换时重置校准
if (modelName && modelName !== _calibrationModel) { if (modelName && modelName !== _calibrationModel) {
_calibrationModel = modelName; _calibrationModel = modelName;
_calibrationRatio = 1.0; _tokenCalibrationRatio = 1.0;
_calibrationSamples = 0; _calibrationSamples = 0;
} }
if (actualInputTokens <= 0 || estimatedCount <= 0) return; if (actualInputTokens <= 0 || estimatedCount <= 0) return;
const actualTotal = actualInputTokens + actualOutputTokens; // 仅用 prompt_eval_count(实际输入 token)与估算值对比,
const sampleRatio = actualTotal / Math.max(1, estimatedCount); // 因为 estimatedCount 只估算消息内容(不含输出 token),加入 eval_count 会导致比值虚高
const sampleRatio = actualInputTokens / Math.max(1, estimatedCount);
// 指数移动平均,平滑异常值 // 指数移动平均,平滑异常值
const alpha = 0.3; const alpha = 0.3;
_tokenCalibrationRatio = _tokenCalibrationRatio * (1 - alpha) + sampleRatio * alpha; _tokenCalibrationRatio = _tokenCalibrationRatio * (1 - alpha) + sampleRatio * alpha;
+1 -1
View File
@@ -794,7 +794,7 @@ export async function extractAndSaveMemories(
for (let i = allMsgs.length - 1; i >= 0 && rounds.length < 15; i--) { for (let i = allMsgs.length - 1; i >= 0 && rounds.length < 15; i--) {
const msg = allMsgs[i]; const msg = allMsgs[i];
if (msg.role === 'assistant') { if (msg.role === 'assistant') {
const round = { assistant: msg.content }; const round: { user?: string; assistant?: string } = { assistant: msg.content };
// 找到前面最近的 user 消息 // 找到前面最近的 user 消息
for (let j = i - 1; j >= 0; j--) { for (let j = i - 1; j >= 0; j--) {
if (allMsgs[j].role === 'user') { if (allMsgs[j].role === 'user') {
+6 -6
View File
@@ -59,21 +59,21 @@ export async function executeSubAgent(
const toolNames = [...SUB_AGENT_TOOL_WHITELIST].join(', '); const toolNames = [...SUB_AGENT_TOOL_WHITELIST].join(', ');
logInfo(`子 Agent 启动`, `任务: ${task.slice(0, 80)} | 工具: ${toolNames} | 模型: ${model}`); logInfo(`子 Agent 启动`, `任务: ${task.slice(0, 80)} | 工具: ${toolNames} | 模型: ${model}`);
// 如果子代理使用了不同于主 AI 的模型,获取该模型的实际上下文长度 // 钳制:取 min(模型支持值, 用户设置值),防止手动设置超过模型能力
const defaultModel = state.get<string>('_defaultModel', ''); const userCtx = state.get<number>(KEYS.NUM_CTX, 131072);
let numCtx = state.get<number>(KEYS.NUM_CTX, 131072); let modelCtx = userCtx;
if (model !== defaultModel) { // 无论是否是默认模型,都获取模型实际上下文长度做钳制
try { try {
const detail = await api.showModel(model); const detail = await api.showModel(model);
const modelInfo = detail.model_info || {}; const modelInfo = detail.model_info || {};
for (const key of Object.keys(modelInfo)) { for (const key of Object.keys(modelInfo)) {
if (key.endsWith('.context_length')) { if (key.endsWith('.context_length')) {
numCtx = Number(modelInfo[key]) || numCtx; modelCtx = Number(modelInfo[key]) || userCtx;
break; break;
} }
} }
} catch { /* 获取失败用默认值 */ } } catch { /* 获取失败用默认值 */ }
} const numCtx = Math.min(modelCtx, userCtx);
const systemPrompt = `你是一个子任务执行 Agent,拥有只读工具权限。请高效完成指定任务,给出结果报告。 const systemPrompt = `你是一个子任务执行 Agent,拥有只读工具权限。请高效完成指定任务,给出结果报告。
+102 -33
View File
@@ -503,36 +503,6 @@ html, body {
border-color: rgba(212, 160, 60, 0.15); 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 { .temp-slider {
width: 100%; width: 100%;
@@ -3738,7 +3708,7 @@ html, body {
/* 概览卡片组 */ /* 概览卡片组 */
.td-overview { .td-overview {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px; gap: 12px;
margin-bottom: 20px; margin-bottom: 20px;
} }
@@ -4104,9 +4074,108 @@ html, body {
padding: 24px !important; padding: 24px !important;
} }
/* 响应式 */ /* 响应式:超窄屏幕强制 2 列 */
@media (max-width: 900px) { @media (max-width: 600px) {
.td-overview { .td-overview {
grid-template-columns: repeat(2, 1fr); 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;
}
+6
View File
@@ -403,6 +403,12 @@ export interface LoopContext {
planRetries: number; planRetries: number;
/** 循环开始时间 */ /** 循环开始时间 */
startTime: number; startTime: number;
/** 上一轮独立 stats(供 onNewIteration / makeStats 使用,避免累计值导致重复计算) */
lastLoopStats?: {
eval_count?: number;
prompt_eval_count?: number;
total_duration?: number;
};
} }
/** Agent 运行模式 */ /** Agent 运行模式 */