v0.14.13: Token Dashboard 上下文计算修复 + 校准比例修正 + 已有TS错误修复
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
</p>
|
||||
|
||||
<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/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">
|
||||
@@ -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
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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()
|
||||
});
|
||||
|
||||
@@ -530,7 +530,7 @@ async function handleRetry(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<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 预算比例:文件内容最多占用上下文窗口的比例 */
|
||||
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) {
|
||||
|
||||
@@ -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<void>
|
||||
}
|
||||
|
||||
async function checkModelCapability(modelName: string): Promise<void> {
|
||||
// 切换模型时清除旧上下文剩余显示
|
||||
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<number>(KEYS.NUM_CTX, 131072);
|
||||
const modelCtx = getModelContextLength();
|
||||
if (modelCtx > 0 && modelCtx < userCtx) {
|
||||
return modelCtx;
|
||||
}
|
||||
return userCtx;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
|
||||
@@ -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, '"')
|
||||
.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<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_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<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;
|
||||
}
|
||||
}
|
||||
|
||||
/** 动态更新表头列 */
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<div class="header-left">
|
||||
<img class="logo" src="./assets/icons/llama.png" alt="logo" />
|
||||
<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="使用帮助">
|
||||
<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"/>
|
||||
@@ -102,8 +102,6 @@
|
||||
<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>
|
||||
</div>
|
||||
<span class="ctx-total" id="ctxTotal" title="模型上下文窗口大小"></span>
|
||||
<span class="ctx-remain" id="ctxRemain" style="display:none;" title="剩余可用上下文"></span>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════ 聊天区域 ═══════════════ -->
|
||||
@@ -732,6 +730,10 @@
|
||||
<button class="td-view-btn active" data-view="global">🌍 全局统计</button>
|
||||
<button class="td-view-btn" data-view="session">💬 当前会话</button>
|
||||
</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-title" id="tdChartTitle">📈 Token 消耗趋势 — 全局</div>
|
||||
<div class="td-chart" id="tdChart"></div>
|
||||
|
||||
@@ -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<void> {
|
||||
state.set(KEYS.CURRENT_SESSION, session);
|
||||
logInfo('新建会话');
|
||||
|
||||
// 清除剩余上下文显示
|
||||
clearCtxRemain();
|
||||
|
||||
// ── 强制中断正在进行的生成 ──
|
||||
const abortController = state.get<AbortController | null>(KEYS.ABORT_CONTROLLER);
|
||||
if (abortController) {
|
||||
@@ -388,8 +384,6 @@ async function init(): Promise<void> {
|
||||
// 反显设置面板的下拉框
|
||||
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);
|
||||
|
||||
@@ -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<boolean>;
|
||||
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<boolean>;
|
||||
}
|
||||
@@ -937,14 +938,16 @@ async function handleInit(
|
||||
ctx.messages.push(userMsg);
|
||||
|
||||
// 上下文窗口管理:滑动窗口 + 摘要压缩 + token 裁剪
|
||||
const numCtx = state.get<number>(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<AbortController | null>(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<boolean>('thinkEnabled', false),
|
||||
options: {
|
||||
num_ctx: state.get<number>(KEYS.NUM_CTX, 131072),
|
||||
num_ctx: getEffectiveNumCtx(),
|
||||
temperature: state.get<number>('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<void> {
|
||||
const numCtx = state.get<number>(KEYS.NUM_CTX, 131072);
|
||||
const numCtx = getEffectiveNumCtx();
|
||||
if (shouldAutoCompress(ctx.messages, numCtx)) {
|
||||
logInfo('COMPRESSING: 上下文压缩触发');
|
||||
const compressAC = state.get<AbortController | null>(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<number>(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);
|
||||
}
|
||||
|
||||
// 状态分发
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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<string>('_defaultModel', '');
|
||||
let numCtx = state.get<number>(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<number>(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,拥有只读工具权限。请高效完成指定任务,给出结果报告。
|
||||
|
||||
|
||||
+102
-33
@@ -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;
|
||||
}
|
||||
|
||||
Vendored
+6
@@ -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 运行模式 */
|
||||
|
||||
Reference in New Issue
Block a user