v0.14.13: Token Dashboard 上下文计算修复 + 校准比例修正 + 已有TS错误修复
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) => ({
|
||||||
@@ -634,11 +638,10 @@ async function handleRetry(): Promise<void> {
|
|||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
await saveCurrentSession();
|
await saveCurrentSession();
|
||||||
if (loopStats?.ctx_tokens) updateCtxRemain(loopStats.ctx_tokens);
|
}
|
||||||
}
|
});
|
||||||
});
|
} catch (err) {
|
||||||
} catch (err) {
|
|
||||||
logError('重试失败', (err as Error).message);
|
logError('重试失败', (err as Error).message);
|
||||||
appendSystemMessage(`❌ 重试失败: ${(err as Error).message}`);
|
appendSystemMessage(`❌ 重试失败: ${(err as Error).message}`);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
/** 打开仪表盘 */
|
/** 打开仪表盘 */
|
||||||
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_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 {
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 动态更新表头列 */
|
/** 动态更新表头列 */
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 状态分发
|
// 状态分发
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+6
@@ -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 运行模式 */
|
||||||
|
|||||||
Reference in New Issue
Block a user