diff --git a/README.md b/README.md index 553e37c..0faef20 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

- version + version electron typescript 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 diff --git a/package-lock.json b/package-lock.json index 35f7b81..3b5f493 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ollama-desktop", - "version": "0.14.12", + "version": "0.14.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ollama-desktop", - "version": "0.14.12", + "version": "0.14.13", "license": "MIT", "dependencies": { "ffmpeg-static": "^5.2.0", diff --git a/package.json b/package.json index 87be502..9f51d19 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ollama-desktop", - "version": "0.14.12", + "version": "0.14.13", "description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端", "main": "dist/main/main.js", "author": "thzxx", diff --git a/src/main/menu.ts b/src/main/menu.ts index 50b5980..0debb41 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -101,7 +101,7 @@ export function createMenu(): void { dialog.showMessageBox(mainWindow!, { type: 'info', title: '关于 Metona Ollama', - message: 'Metona Ollama Desktop v0.14.12', + message: 'Metona Ollama Desktop v0.14.13', detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama', icon: getIconPath() }); diff --git a/src/renderer/components/input-area.ts b/src/renderer/components/input-area.ts index 8ca6f13..e9ac1d7 100644 --- a/src/renderer/components/input-area.ts +++ b/src/renderer/components/input-area.ts @@ -530,7 +530,7 @@ async function handleRetry(): Promise { result: null, status: 'pending', timestamp: Date.now() }); }, - onNewIteration: (toolCalls) => { + onNewIteration: (toolCalls, stats) => { removeCurrentPlaceholder(); const hasContent = !!retryContent?.trim(); if (hasContent || retryIterations === 0) { @@ -540,6 +540,10 @@ async function handleRetry(): Promise { timestamp: now, ...(retryThinkContent && { think: retryThinkContent }), ...(retryIterationToolRecords.length > 0 && { toolCalls: [...retryIterationToolRecords] }), + // P0 修复:中间迭代消息携带本轮独立 token 统计 + ...(stats?.eval_count && { eval_count: stats.eval_count }), + ...(stats?.prompt_eval_count && { prompt_eval_count: stats.prompt_eval_count }), + ...(stats?.total_duration && { total_duration: stats.total_duration }), }; retryIterationToolRecords = []; state.update(KEYS.CURRENT_SESSION, (s: any) => ({ @@ -634,11 +638,10 @@ async function handleRetry(): Promise { updatedAt: Date.now() })); } - await saveCurrentSession(); - if (loopStats?.ctx_tokens) updateCtxRemain(loopStats.ctx_tokens); - } - }); - } catch (err) { + await saveCurrentSession(); + } + }); + } catch (err) { logError('重试失败', (err as Error).message); appendSystemMessage(`❌ 重试失败: ${(err as Error).message}`); } finally { @@ -798,34 +801,6 @@ function stopGeneration(): void { if (abortController) abortController.abort(); } -/** 更新剩余上下文显示(使用 estimateTokens 估算实际占用,非 Ollama eval_count) */ -function updateCtxRemain(ctxTokens?: number): void { - const el = document.querySelector('#ctxRemain') as HTMLElement; - if (!el) return; - const total = state.get(KEYS.NUM_CTX, 0); - if (!total || !ctxTokens || ctxTokens <= 0) { - el.style.display = 'none'; - return; - } - const remain = total - ctxTokens; - if (remain <= 0) { - el.style.display = 'inline-flex'; - el.textContent = '⚠ 上下文已满'; - el.style.color = 'var(--danger, #e74c3c)'; - } else { - el.style.display = 'inline-flex'; - el.style.color = ''; - const remainFmt = remain >= 1024 ? `${(remain / 1024).toFixed(0)}K` : String(remain); - const usedFmt = ctxTokens >= 1024 ? `${(ctxTokens / 1024).toFixed(0)}K` : String(ctxTokens); - el.textContent = `${usedFmt} / ${total >= 1024 ? (total/1024).toFixed(0)+'K' : total}`; - } -} - -export function clearCtxRemain(): void { - const el = document.querySelector('#ctxRemain') as HTMLElement; - if (el) el.style.display = 'none'; -} - /** Token 预算比例:文件内容最多占用上下文窗口的比例 */ const FILE_TOKEN_BUDGET_RATIO = 0.3; @@ -1264,7 +1239,7 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio result: null, status: 'pending', timestamp: Date.now() }); }, - onNewIteration: (toolCalls) => { + onNewIteration: (toolCalls, stats) => { // 保存上一轮的卡片(含工具记录) const prevMsg: ChatMessage = { role: 'assistant', @@ -1273,6 +1248,10 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio timestamp: Date.now(), ...(thinkContent && { think: thinkContent }), ...(currentIterationToolRecords.length > 0 && { toolCalls: [...currentIterationToolRecords] }), + // P0 修复:中间迭代消息携带本轮独立 token 统计 + ...(stats?.eval_count && { eval_count: stats.eval_count }), + ...(stats?.prompt_eval_count && { prompt_eval_count: stats.prompt_eval_count }), + ...(stats?.total_duration && { total_duration: stats.total_duration }), }; currentIterationToolRecords = []; state.update(KEYS.CURRENT_SESSION, (session: any) => ({ @@ -1372,8 +1351,6 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio })); } await saveCurrentSession(); - // 更新剩余上下文 — P0 修复:应使用 ctx_tokens(总上下文占用估算)而非 prompt_eval_count(仅最后一轮输入 token) - if (loopStats?.ctx_tokens) updateCtxRemain(loopStats.ctx_tokens); } }); } catch (err) { diff --git a/src/renderer/components/model-bar.ts b/src/renderer/components/model-bar.ts index 15071e1..041f277 100644 --- a/src/renderer/components/model-bar.ts +++ b/src/renderer/components/model-bar.ts @@ -8,7 +8,6 @@ import { formatSize } from '../utils/utils.js'; import { OllamaAPI } from '../api/ollama.js'; import { ChatDB } from '../db/chat-db.js'; import { logModel, logDebug, logError, logWarn } from '../services/log-service.js'; -import { clearCtxRemain } from './input-area.js'; import type { OllamaModelDetail, ModelCaps } from '../types.js'; let modelSelectEl: HTMLSelectElement; @@ -170,9 +169,6 @@ async function filterEmbedModels(models: Array<{ name: string }>): Promise } async function checkModelCapability(modelName: string): Promise { - // 切换模型时清除旧上下文剩余显示 - clearCtxRemain(); - if (modelCapabilityCache.has(modelName)) { const cached = modelCapabilityCache.get(modelName)!; applyCapability(modelName, cached); @@ -294,15 +290,25 @@ export function formatCtxLen(n: number): string { return String(n); } -/** 更新模型栏上下文长度显示(显示用户配置的值,而非模型自身值) */ -function updateCtxTotal(contextLength: number): void { - const el = document.querySelector('#ctxTotal') as HTMLElement; - if (!el) return; - el.style.display = 'inline-flex'; - el.textContent = formatCtxLen(contextLength); +/** 获取当前选中模型自身支持的上下文长度 */ +export function getModelContextLength(): number { + const model = modelSelectEl?.value; + if (!model) return 0; + const caps = modelCapabilityCache.get(model); + return caps?.contextLength || 0; } -/** 外部调用:更新上下文长度显示 */ -export function updateCtxTotalExternal(contextLength: number): void { - updateCtxTotal(contextLength); +/** 获取当前选中模型的名称 */ +export function getCurrentModelName(): string { + return modelSelectEl?.value || ''; +} + +/** 获取有效的上下文长度:min(模型支持值, 用户设置值) */ +export function getEffectiveNumCtx(): number { + const userCtx = state.get(KEYS.NUM_CTX, 131072); + const modelCtx = getModelContextLength(); + if (modelCtx > 0 && modelCtx < userCtx) { + return modelCtx; + } + return userCtx; } diff --git a/src/renderer/components/settings-modal.ts b/src/renderer/components/settings-modal.ts index 6762c33..2e778a3 100644 --- a/src/renderer/components/settings-modal.ts +++ b/src/renderer/components/settings-modal.ts @@ -7,7 +7,7 @@ import { debounce } from '../utils/utils.js'; import { encryptData, decryptData } from '../services/crypto.js'; import { logSetting, logInfo, logWarn, logError, logSuccess } from '../services/log-service.js'; import { checkConnection, updateConnectionInfo, updateRunningModels } from './header.js'; -import { loadModels, updateCtxTotalExternal, formatCtxLen } from './model-bar.js'; +import { loadModels, formatCtxLen } from './model-bar.js'; import { showToast } from './toast.js'; import { showConfirm } from './prompt-modal.js'; import { OllamaAPI } from '../api/ollama.js'; @@ -71,8 +71,6 @@ export function initSettingsModal(): void { if (!val) return; state.set(KEYS.NUM_CTX, val); if (db) await db.saveSetting('numCtx', val); - // 更新模型栏显示 - updateCtxTotalExternal(val); logSetting('上下文长度', formatCtxLen(val)); }); diff --git a/src/renderer/components/token-dashboard.ts b/src/renderer/components/token-dashboard.ts index 174958e..d682fcb 100644 --- a/src/renderer/components/token-dashboard.ts +++ b/src/renderer/components/token-dashboard.ts @@ -5,7 +5,9 @@ import { state, KEYS } from '../state/state.js'; import { formatTime } from '../utils/utils.js'; -import { logError, logInfo } from '../services/log-service.js'; +import { logError } from '../services/log-service.js'; +import { getEffectiveNumCtx, getModelContextLength } from './model-bar.js'; +import { estimateTokens } from '../services/context-manager.js'; import type { ChatSession, ChatMessage } from '../types.js'; let dashboardModalEl: HTMLElement; @@ -37,14 +39,21 @@ interface AllSessionsTokenStats { totals: GlobalTokenTotals; } -let globalStatsCache: AllSessionsTokenStats | null = null; -let globalStatsLoaded = false; +/** P1 #5: HTML 转义,防止 XSS */ +function escapeHtml(s: unknown): string { + if (s == null) return ''; + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} /** 打开仪表盘 */ export function openTokenDashboard(): void { if (!dashboardModalEl) return; dashboardModalEl.style.display = ''; - globalStatsLoaded = false; renderDashboard(); startAutoRefresh(); } @@ -78,7 +87,6 @@ export function initTokenDashboard(): void { toggleEl.querySelectorAll('.td-view-btn').forEach(btn => btn.classList.remove('active')); target.classList.add('active'); // 切换时刷新 - if (viewMode === 'global') globalStatsLoaded = false; renderDashboard(); } }); @@ -143,16 +151,19 @@ function formatTokenCount(n: number): string { return String(n); } -/** 获取全局统计数据(带缓存) */ +/** P1 #3: 上次成功获取的全局数据(用于刷新期间保持旧数据可见,避免闪烁) */ +let lastSuccessfulStats: AllSessionsTokenStats | null = null; +let hasEverLoaded = false; + +/** P1 #3: 获取全局统计数据 — 每次都重新查询,不做永久缓存 */ async function fetchGlobalStats(): Promise { - if (globalStatsLoaded && globalStatsCache) return globalStatsCache; try { const desktop = window.metonaDesktop; if (!desktop?.db?.getAllTokenStats) return null; const result = await desktop.db.getAllTokenStats(); if (result) { - globalStatsCache = result; - globalStatsLoaded = true; + lastSuccessfulStats = result; + hasEverLoaded = true; return result; } } catch (err) { @@ -162,6 +173,10 @@ async function fetchGlobalStats(): Promise { } function renderDashboard(): void { + // 全局视图时隐藏上下文环形图区域(仅会话视图显示) + const ringSection = dashboardModalEl.querySelector('#tdCtxRingSection') as HTMLElement | null; + if (ringSection) ringSection.style.display = viewMode === 'session' ? '' : 'none'; + if (viewMode === 'global') { renderGlobalView(); } else { @@ -173,7 +188,14 @@ function renderDashboard(): void { function renderGlobalView(): void { fetchGlobalStats().then(stats => { if (!stats) { - renderGlobalFallback(); + // P1 #3/#9: 刷新失败时保留旧数据,仅在从未加载成功时显示错误 + if (hasEverLoaded && lastSuccessfulStats) { + renderGlobalOverview(lastSuccessfulStats); + renderGlobalChart(lastSuccessfulStats); + renderGlobalTable(lastSuccessfulStats); + } else { + renderGlobalFallback(true); + } return; } renderGlobalOverview(stats); @@ -183,19 +205,20 @@ function renderGlobalView(): void { } /** 全局统计获取失败时的降级处理 */ -function renderGlobalFallback(): void { +function renderGlobalFallback(isError = false): void { const overviewEl = dashboardModalEl.querySelector('#tdOverview')!; + const msg = isError ? '全局统计加载失败,正在重试…' : '正在加载全局数据…'; overviewEl.innerHTML = `

🌍
-
-
全局统计加载中…
+
${msg}
`; const chartEl = dashboardModalEl.querySelector('#tdChart')!; - chartEl.innerHTML = '
正在加载全局数据…
'; + chartEl.innerHTML = `
${msg}
`; const tableEl = dashboardModalEl.querySelector('#tdTableBody')!; - tableEl.innerHTML = '正在加载…'; + tableEl.innerHTML = `${msg}`; } /** 渲染全局概览卡片 */ @@ -239,7 +262,6 @@ function renderGlobalOverview(stats: AllSessionsTokenStats): void { function renderBarChart( chartEl: HTMLElement, data: Array<{ label: string; input: number; output: number; tooltip: string }>, - title: string, note?: string, savedScrollLeft?: number ): void { @@ -264,7 +286,7 @@ function renderBarChart( const inputPct = total > 0 ? (d.input / total) * 100 : 0; const outputPct = 100 - inputPct; return ` -
+
@@ -275,7 +297,7 @@ function renderBarChart( ${formatTokenCount(d.output)} ${formatTokenCount(d.input)}
-
${d.label}
+
${escapeHtml(d.label)}
`; }).join(''); @@ -284,7 +306,7 @@ function renderBarChart(
输出 Token 输入 Token - ${note ? `${note}` : ''} + ${note ? `${escapeHtml(note)}` : ''}
${gridLines} @@ -314,7 +336,7 @@ function renderGlobalChart(stats: AllSessionsTokenStats): void { return; } - // 按创建时间正序排列,取最近 30 个会话 + // P2 #10: 统一排序 — 按 created_at 正序(图表左→右为时间递增) const sorted = [...sessions].sort((a, b) => a.created_at - b.created_at).slice(-30); const chartData = sorted.map(s => ({ @@ -324,7 +346,7 @@ function renderGlobalChart(stats: AllSessionsTokenStats): void { tooltip: `${s.title} · 模型 ${s.model} · 输入 ${formatTokenCount(s.total_input)} + 输出 ${formatTokenCount(s.total_output)} = ${formatTokenCount(s.total_input + s.total_output)} tokens · ${s.round_count} 轮`, })); - renderBarChart(chartEl, chartData, '按会话', `最近 ${sorted.length} 个会话`, savedScrollLeft); + renderBarChart(chartEl, chartData, `最近 ${sorted.length} 个会话`, savedScrollLeft); } /** 渲染全局明细表格 */ @@ -335,6 +357,10 @@ function renderGlobalTable(stats: AllSessionsTokenStats): void { // 动态更新表头 updateTableHeader('global'); + // P1 #4: 保存表格滚动位置 + const tableWrapEl = dashboardModalEl.querySelector('.td-table-wrap') as HTMLElement | null; + const savedScrollTop = tableWrapEl?.scrollTop ?? 0; + const tableEl = dashboardModalEl.querySelector('#tdTableBody')!; const sessions = stats.sessions; @@ -343,12 +369,24 @@ function renderGlobalTable(stats: AllSessionsTokenStats): void { return; } - tableEl.innerHTML = sessions.map(s => { + // P2 #10: 统一排序 — 按 created_at 倒序(最新创建的在前) + const sorted = [...sessions].sort((a, b) => b.created_at - a.created_at); + + // P2 #8: 限制表格行数,避免大量数据导致 DOM 性能问题 + const MAX_ROWS = 50; + const display = sorted.slice(0, MAX_ROWS); + + // 如果有截断,在表格标题中标注 + if (tableTitleEl && sorted.length > MAX_ROWS) { + tableTitleEl.textContent = `📋 会话汇总明细(最近 ${MAX_ROWS} 条 / 共 ${sorted.length} 条)`; + } + + tableEl.innerHTML = display.map(s => { const total = s.total_input + s.total_output; return ` - ${s.title.length > 20 ? s.title.slice(0, 20) + '…' : s.title} - ${s.model} + ${escapeHtml(s.title.length > 20 ? s.title.slice(0, 20) + '…' : s.title)} + ${escapeHtml(s.model)} ${formatTokenCount(s.total_input)} ${formatTokenCount(s.total_output)} ${formatTokenCount(total)} @@ -357,12 +395,141 @@ function renderGlobalTable(stats: AllSessionsTokenStats): void { `; }).join(''); + + // P1 #4: 恢复表格滚动位置 + if (savedScrollTop > 0) { + const newWrapEl = dashboardModalEl.querySelector('.td-table-wrap') as HTMLElement | null; + if (newWrapEl) newWrapEl.scrollTop = savedScrollTop; + } +} + +/** + * 计算当前已用上下文 token 数。 + * 优先使用最后一条 assistant 消息的 prompt_eval_count(Ollama 精确值), + * 再加上后续新增消息的估算 token(含 toolCalls/images 开销)。 + * 无实际数据时回退到增强估算(全量消息内容 + toolCalls/images 开销)。 + */ +function computeUsedContextTokens(messages: ChatMessage[]): { usedTokens: number; isActual: boolean } { + // 从末尾向前查找最后一条携带 prompt_eval_count 的 assistant 消息 + let lastActualIdx = -1; + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]; + if (m.role === 'assistant' && m.prompt_eval_count && m.prompt_eval_count > 0) { + lastActualIdx = i; + break; + } + } + + if (lastActualIdx >= 0) { + const lastActual = messages[lastActualIdx]; + // 基准:Ollama 返回的实际输入 token + 输出 token + let base = lastActual.prompt_eval_count! + (lastActual.eval_count || 0); + + // 累加 lastActual 之后新增消息的估算 token + for (let i = lastActualIdx + 1; i < messages.length; i++) { + const m = messages[i]; + base += estimateTokens(m.content || ''); + if (m.toolCalls?.length) base += m.toolCalls.length * 50; + if (m.images?.length) base += m.images.length * 100; + } + return { usedTokens: base, isActual: true }; + } + + // 无实际数据:增强估算(含 toolCalls/images 开销) + let estimated = 0; + for (const m of messages) { + estimated += estimateTokens(m.content || ''); + if (m.toolCalls?.length) estimated += m.toolCalls.length * 50; + if (m.images?.length) estimated += m.images.length * 100; + } + return { usedTokens: estimated, isActual: false }; +} + +/** 渲染上下文使用率环形图 */ +function renderCtxRing(messages: ChatMessage[], isStreaming: boolean): void { + const ringSection = dashboardModalEl.querySelector('#tdCtxRingSection') as HTMLElement; + const ringEl = dashboardModalEl.querySelector('#tdCtxRing') as HTMLElement; + if (!ringSection || !ringEl) return; + + ringSection.style.display = ''; + + const effectiveCtx = getEffectiveNumCtx(); + const modelCtx = getModelContextLength(); + const userCtx = state.get(KEYS.NUM_CTX, 131072); + + // ── 计算已用上下文 token ── + // 优先使用 Ollama 返回的实际 prompt_eval_count(精确值), + // 无实际数据时回退到增强估算(含 toolCalls/images 开销) + const { usedTokens, isActual } = computeUsedContextTokens(messages); + const remainTokens = Math.max(effectiveCtx - usedTokens, 0); + const usagePct = effectiveCtx > 0 ? Math.min((usedTokens / effectiveCtx) * 100, 100) : 0; + + // SVG 环形图参数 + const size = 140; + const strokeWidth = 12; + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const dashOffset = circumference * (1 - usagePct / 100); + + // 颜色根据使用率变化 + let ringColor = '#9B7ED8'; // 紫色 - 正常 + if (usagePct >= 90) ringColor = '#E8734A'; // 珊瑚红 - 危险 + else if (usagePct >= 70) ringColor = '#F0A040'; // 橙色 - 警告 + + // 钳制说明文字 + const clampNote = modelCtx > 0 && modelCtx < userCtx + ? `模型限制 ${formatTokenCount(modelCtx)} < 设置值 ${formatTokenCount(userCtx)},采用 ${formatTokenCount(effectiveCtx)}` + : `设置值 ${formatTokenCount(userCtx)}`; + + ringEl.innerHTML = ` +
+ + + + + ${usagePct.toFixed(0)}% + + + 已用 / 总量 + + +
+
+ + 已用上下文 + ${formatTokenCount(usedTokens)} +
+
+ + 剩余上下文 + ${formatTokenCount(remainTokens)} +
+
+ 有效上下文窗口 + ${formatTokenCount(effectiveCtx)} +
+
${escapeHtml(clampNote)}
+
${isActual ? '✅ 基于 Ollama 实际计数' : '⚙️ 基于估算(含工具/图片开销)'}
+ ${isStreaming ? '
⚡ 实时更新中…
' : ''} +
+
+ `; } /** 渲染当前会话视图 */ function renderSessionView(): void { const session = state.get(KEYS.CURRENT_SESSION); const messages = session?.messages || []; + const isStreaming = state.get(KEYS.IS_STREAMING, false); const userMsgs = messages.filter(m => m.role === 'user'); const aiMsgs = messages.filter(m => m.role === 'assistant'); @@ -374,6 +541,13 @@ function renderSessionView(): void { const totalDurationNs = rounds.reduce((sum, r) => sum + r.total_duration, 0); const avgTokens = rounds.length > 0 ? Math.round(totalTokens / rounds.length) : 0; + // P3 #12: 从最后一条 assistant 消息获取实际使用的模型 + const lastAssistantMsg = [...messages].reverse().find(m => m.role === 'assistant' && m.model); + const currentModel = lastAssistantMsg?.model || session?.model || '-'; + + // 渲染上下文使用率环形图 + renderCtxRing(messages, isStreaming); + // 概览区域 const overviewEl = dashboardModalEl.querySelector('#tdOverview')!; overviewEl.innerHTML = ` @@ -400,7 +574,7 @@ function renderSessionView(): void {
🤖
-
${session?.model || '-'}
+
${escapeHtml(currentModel)}
当前模型
`; @@ -420,19 +594,30 @@ function renderSessionView(): void { const chartTitleEl = dashboardModalEl.querySelector('#tdChartTitle'); if (chartTitleEl) chartTitleEl.textContent = '📈 Token 消耗趋势 — 按轮次'; - renderBarChart(chartEl, chartData, '按轮次', undefined, savedScrollLeft); + renderBarChart(chartEl, chartData, undefined, savedScrollLeft); // 表格标题 const tableTitleEl = dashboardModalEl.querySelector('#tdTableTitle') as HTMLElement | null; - if (tableTitleEl) tableTitleEl.textContent = '📋 轮次明细(按时间倒序)'; + // P1 #6: 流式输出期间显示提示 + if (tableTitleEl) { + tableTitleEl.textContent = isStreaming + ? '📋 轮次明细(按时间倒序)— ⚡ AI 回复中,数据实时更新…' + : '📋 轮次明细(按时间倒序)'; + } // 动态更新表头 updateTableHeader('session'); + // P1 #4: 保存表格滚动位置 + const tableWrapEl = dashboardModalEl.querySelector('.td-table-wrap') as HTMLElement | null; + const savedScrollTop = tableWrapEl?.scrollTop ?? 0; + // 明细表格 const tableEl = dashboardModalEl.querySelector('#tdTableBody')!; if (rounds.length === 0) { - tableEl.innerHTML = '暂无数据'; + tableEl.innerHTML = isStreaming + ? '⏳ AI 正在回复中,暂无统计数据…' + : '暂无数据'; } else { const sorted = [...rounds].reverse(); tableEl.innerHTML = sorted.map(r => { @@ -440,7 +625,7 @@ function renderSessionView(): void { return ` R${r.round} - 🤖 AI${r.model ? ` ${r.model}` : ''} + 🤖 AI${r.model ? ` ${escapeHtml(r.model)}` : ''} ${formatTokenCount(r.prompt_eval_count)} ${formatTokenCount(r.eval_count)} ${formatTokenCount(total)} @@ -450,6 +635,12 @@ function renderSessionView(): void { `; }).join(''); } + + // P1 #4: 恢复表格滚动位置 + if (savedScrollTop > 0) { + const newWrapEl = dashboardModalEl.querySelector('.td-table-wrap') as HTMLElement | null; + if (newWrapEl) newWrapEl.scrollTop = savedScrollTop; + } } /** 动态更新表头列 */ diff --git a/src/renderer/index.html b/src/renderer/index.html index 3bea339..e367777 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -28,7 +28,7 @@
Metona Ollama - v0.14.12 + v0.14.13
- -
@@ -732,6 +730,10 @@
+
📈 Token 消耗趋势 — 全局
diff --git a/src/renderer/main.ts b/src/renderer/main.ts index 314fa77..8f6607c 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -14,10 +14,9 @@ import { generateId } from './utils/utils.js'; import { initToast, showToast } from './components/toast.js'; import { initLightbox, closeLightbox } from './components/lightbox.js'; import { initHeader, checkConnection } from './components/header.js'; -import { initModelBar, loadModels, setSelectedModel, updateCtxTotalExternal } from './components/model-bar.js'; +import { initModelBar, loadModels, setSelectedModel } from './components/model-bar.js'; import { initChatArea, renderMessages, clearMessages, enableAutoScroll } from './components/chat-area.js'; import { initInputArea } from './components/input-area.js'; -import { clearCtxRemain } from './components/input-area.js'; import { initSettingsModal, closeSettingsModal } from './components/settings-modal.js'; import { initHistoryModal, closeHistoryModal } from './components/history-modal.js'; import { initMemoryModal } from './components/memory-modal.js'; @@ -257,9 +256,6 @@ async function startNewSession(): Promise { state.set(KEYS.CURRENT_SESSION, session); logInfo('新建会话'); - // 清除剩余上下文显示 - clearCtxRemain(); - // ── 强制中断正在进行的生成 ── const abortController = state.get(KEYS.ABORT_CONTROLLER); if (abortController) { @@ -388,8 +384,6 @@ async function init(): Promise { // 反显设置面板的下拉框 const ctxSelect = document.querySelector('#selectContextLength') as HTMLSelectElement; if (ctxSelect) ctxSelect.value = String(numCtx); - // 更新模型栏上下文显示 - updateCtxTotalExternal(numCtx); (document.querySelector('#inputTemperature') as HTMLInputElement).value = String(temperature); document.querySelector('#tempValue')!.textContent = parseFloat(String(temperature)).toFixed(1); diff --git a/src/renderer/services/agent-engine.ts b/src/renderer/services/agent-engine.ts index 1d83d05..f710fb1 100644 --- a/src/renderer/services/agent-engine.ts +++ b/src/renderer/services/agent-engine.ts @@ -25,6 +25,7 @@ import { buildContext, estimateTokens, shouldAutoCompress, compressWithLLM, AUTO import { runCompletionGate } from './completion-gate.js'; import { executeHooks } from './hooks.js'; import { recordIteration, recordToolCall, recordCompletionGate, startSessionMetrics, endSessionMetrics } from './agent-metrics.js'; +import { getEffectiveNumCtx } from '../components/model-bar.js'; import type { OllamaMessage, OllamaStreamChunk, @@ -607,7 +608,7 @@ export interface AgentCallbacks { onToolCallError: (name: string, error: string, call: ToolCall) => void; onDone: (finalContent: string, toolRecords?: ToolCallRecord[], stats?: { eval_count?: number; prompt_eval_count?: number; total_duration?: number; ctx_tokens?: number }) => void; onConfirmTool: (call: ToolCall) => Promise; - onNewIteration?: (toolCalls?: ToolCall[]) => void; + onNewIteration?: (toolCalls?: ToolCall[], stats?: { eval_count?: number; prompt_eval_count?: number; total_duration?: number }) => void; /** Plan Mode: 计划已生成,等待用户确认 */ onPlanReady?: (plan: string, steps: string[]) => Promise; } @@ -937,14 +938,16 @@ async function handleInit( ctx.messages.push(userMsg); // 上下文窗口管理:滑动窗口 + 摘要压缩 + token 裁剪 - const numCtx = state.get(KEYS.NUM_CTX, 131072); + // 钳制:取 min(模型支持值, 用户设置值),防止手动设置超过模型能力 + const numCtx = getEffectiveNumCtx(); const contextResult = buildContext(ctx.messages, { maxTokens: numCtx, windowSize: 20 }); ctx.messages.length = 0; ctx.messages.push(...contextResult); - // 自动压缩检测 - if (shouldAutoCompress(ctx.messages, numCtx)) { - logInfo(`自动上下文压缩触发: tokens≈${estimateTokens(ctx.messages.map(m => m.content || '').join(''))} > ${Math.floor(numCtx * AUTO_COMPRESS_THRESHOLD)} (50% of ${numCtx})`); + // 钳制:取 min(模型支持值, 用户设置值) + const effectiveNumCtx = getEffectiveNumCtx(); + if (shouldAutoCompress(ctx.messages, effectiveNumCtx)) { + logInfo(`自动上下文压缩触发: tokens≈${estimateTokens(ctx.messages.map(m => m.content || '').join(''))} > ${Math.floor(effectiveNumCtx * AUTO_COMPRESS_THRESHOLD)} (50% of ${effectiveNumCtx})`); const compressAC = state.get(KEYS.ABORT_CONTROLLER) || new AbortController(); try { const compressed = await compressWithLLM(ctx.messages, api as any, model, { abortController: compressAC }); @@ -1337,7 +1340,7 @@ async function handleThinking( stream: true, think: state.get('thinkEnabled', false), options: { - num_ctx: state.get(KEYS.NUM_CTX, 131072), + num_ctx: getEffectiveNumCtx(), temperature: state.get('temperature', 0.7) }, ...(getEnabledToolDefinitions().length > 0 && { tools: getEnabledToolDefinitions() }) @@ -1394,6 +1397,13 @@ async function handleThinking( abortController ); + // P0 修复:保存本轮独立 stats(供 onNewIteration / makeStats 使用,避免累计值导致重复计算) + ctx.lastLoopStats = { + eval_count: ctx.loopEvalCount || undefined, + prompt_eval_count: ctx.loopPromptEvalCount || undefined, + total_duration: ctx.loopInferenceNs || undefined, + }; + // 累加 token 统计 ctx.totalEvalCount += ctx.loopEvalCount; ctx.totalPromptEvalCount += ctx.loopPromptEvalCount; @@ -2026,7 +2036,7 @@ async function handleCompressing( api: OllamaAPI, model: string, ): Promise { - const numCtx = state.get(KEYS.NUM_CTX, 131072); + const numCtx = getEffectiveNumCtx(); if (shouldAutoCompress(ctx.messages, numCtx)) { logInfo('COMPRESSING: 上下文压缩触发'); const compressAC = state.get(KEYS.ABORT_CONTROLLER) || new AbortController(); @@ -2050,11 +2060,25 @@ async function handleCompressing( // ═══════════════════════════════════════════════════════════════ function makeStats(ctx: LoopContext) { + // P0 修复:返回本轮独立值而非累计值,避免与中间消息重复计算 + // ctx_tokens 优先使用 Ollama 返回的实际 prompt_eval_count,回退到增强估算 + let ctxTokens: number; + if (ctx.loopPromptEvalCount > 0) { + ctxTokens = ctx.loopPromptEvalCount + (ctx.loopEvalCount || 0); + } else { + // 增强估算:消息内容 + tool_calls/images 开销 + ctxTokens = ctx.messages.reduce((sum, m) => { + let t = estimateTokens(m.content || ''); + if (m.tool_calls?.length) t += m.tool_calls.length * 50; + if (m.images?.length) t += m.images.length * 100; + return sum + t; + }, 0); + } return { - eval_count: ctx.totalEvalCount || undefined, - prompt_eval_count: ctx.totalPromptEvalCount || undefined, - total_duration: ctx.totalInferenceNs || undefined, - ctx_tokens: estimateTokens(ctx.messages.map(m => m.content || '').join('')), // 实际上下文占用 + eval_count: ctx.lastLoopStats?.eval_count || undefined, + prompt_eval_count: ctx.lastLoopStats?.prompt_eval_count || undefined, + total_duration: ctx.lastLoopStats?.total_duration || undefined, + ctx_tokens: ctxTokens, }; } @@ -2097,6 +2121,7 @@ export async function runAgentLoop( mode, planRetries: 0, startTime: Date.now(), + lastLoopStats: undefined, }; state.set('_loopState', S.INIT); @@ -2148,7 +2173,7 @@ export async function runAgentLoop( } // Token 感知的动态迭代预算 - const numCtx = state.get(KEYS.NUM_CTX, 131072); + const numCtx = getEffectiveNumCtx(); const usageRatio = estimateTokens(ctx.messages.map(m => m.content || '').join('')) / numCtx; // P2-2: 上下文健康度软收敛 — 60% 时提前预警,让 AI 主动收敛 @@ -2170,7 +2195,7 @@ export async function runAgentLoop( // 仅在 THINKING 阶段通知 UI(一次迭代只通知一次,不是每个状态切换都通知) if (ctx.loopCount > 0 && ctx.state === S.THINKING && callbacks.onNewIteration) { - callbacks.onNewIteration(ctx.prevToolCalls.length > 0 ? ctx.prevToolCalls : undefined); + callbacks.onNewIteration(ctx.prevToolCalls.length > 0 ? ctx.prevToolCalls : undefined, ctx.lastLoopStats); } // 状态分发 diff --git a/src/renderer/services/context-manager.ts b/src/renderer/services/context-manager.ts index ecf26df..44e4776 100644 --- a/src/renderer/services/context-manager.ts +++ b/src/renderer/services/context-manager.ts @@ -28,12 +28,13 @@ export function recordActualTokens(actualInputTokens: number, actualOutputTokens // C8: 模型切换时重置校准 if (modelName && modelName !== _calibrationModel) { _calibrationModel = modelName; - _calibrationRatio = 1.0; + _tokenCalibrationRatio = 1.0; _calibrationSamples = 0; } if (actualInputTokens <= 0 || estimatedCount <= 0) return; - const actualTotal = actualInputTokens + actualOutputTokens; - const sampleRatio = actualTotal / Math.max(1, estimatedCount); + // 仅用 prompt_eval_count(实际输入 token)与估算值对比, + // 因为 estimatedCount 只估算消息内容(不含输出 token),加入 eval_count 会导致比值虚高 + const sampleRatio = actualInputTokens / Math.max(1, estimatedCount); // 指数移动平均,平滑异常值 const alpha = 0.3; _tokenCalibrationRatio = _tokenCalibrationRatio * (1 - alpha) + sampleRatio * alpha; diff --git a/src/renderer/services/memory-service.ts b/src/renderer/services/memory-service.ts index 085acb1..022386c 100644 --- a/src/renderer/services/memory-service.ts +++ b/src/renderer/services/memory-service.ts @@ -794,7 +794,7 @@ export async function extractAndSaveMemories( for (let i = allMsgs.length - 1; i >= 0 && rounds.length < 15; i--) { const msg = allMsgs[i]; if (msg.role === 'assistant') { - const round = { assistant: msg.content }; + const round: { user?: string; assistant?: string } = { assistant: msg.content }; // 找到前面最近的 user 消息 for (let j = i - 1; j >= 0; j--) { if (allMsgs[j].role === 'user') { diff --git a/src/renderer/services/sub-agent.ts b/src/renderer/services/sub-agent.ts index 76d9d87..99a16b3 100644 --- a/src/renderer/services/sub-agent.ts +++ b/src/renderer/services/sub-agent.ts @@ -59,21 +59,21 @@ export async function executeSubAgent( const toolNames = [...SUB_AGENT_TOOL_WHITELIST].join(', '); logInfo(`子 Agent 启动`, `任务: ${task.slice(0, 80)} | 工具: ${toolNames} | 模型: ${model}`); - // 如果子代理使用了不同于主 AI 的模型,获取该模型的实际上下文长度 - const defaultModel = state.get('_defaultModel', ''); - let numCtx = state.get(KEYS.NUM_CTX, 131072); - if (model !== defaultModel) { - try { - const detail = await api.showModel(model); - const modelInfo = detail.model_info || {}; - for (const key of Object.keys(modelInfo)) { - if (key.endsWith('.context_length')) { - numCtx = Number(modelInfo[key]) || numCtx; - break; - } + // 钳制:取 min(模型支持值, 用户设置值),防止手动设置超过模型能力 + const userCtx = state.get(KEYS.NUM_CTX, 131072); + let modelCtx = userCtx; + // 无论是否是默认模型,都获取模型实际上下文长度做钳制 + try { + const detail = await api.showModel(model); + const modelInfo = detail.model_info || {}; + for (const key of Object.keys(modelInfo)) { + if (key.endsWith('.context_length')) { + modelCtx = Number(modelInfo[key]) || userCtx; + break; } - } catch { /* 获取失败用默认值 */ } - } + } + } catch { /* 获取失败用默认值 */ } + const numCtx = Math.min(modelCtx, userCtx); const systemPrompt = `你是一个子任务执行 Agent,拥有只读工具权限。请高效完成指定任务,给出结果报告。 diff --git a/src/renderer/styles/style.css b/src/renderer/styles/style.css index 8488d97..6baa664 100644 --- a/src/renderer/styles/style.css +++ b/src/renderer/styles/style.css @@ -503,36 +503,6 @@ html, body { border-color: rgba(212, 160, 60, 0.15); } -/* ── 上下文长度胶囊(模型栏) ── */ -.ctx-total { - display: inline-flex; - align-items: center; - font-size: 11px; - font-weight: 600; - white-space: nowrap; - padding: 3px 10px; - border-radius: 20px; - color: #47848F; - background: rgba(71, 132, 143, 0.08); - border: 1px solid rgba(71, 132, 143, 0.15); - margin-left: 6px; -} - -/* ── 剩余上下文胶囊 ── */ -.ctx-remain { - display: inline-flex; - align-items: center; - font-size: 11px; - font-weight: 600; - white-space: nowrap; - padding: 3px 10px; - border-radius: 20px; - color: #4CAF50; - background: rgba(76, 175, 80, 0.08); - border: 1px solid rgba(76, 175, 80, 0.15); - margin-left: 2px; -} - /* ── 温度滑块 ── */ .temp-slider { width: 100%; @@ -3738,7 +3708,7 @@ html, body { /* 概览卡片组 */ .td-overview { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin-bottom: 20px; } @@ -4104,9 +4074,108 @@ html, body { padding: 24px !important; } -/* 响应式 */ -@media (max-width: 900px) { +/* 响应式:超窄屏幕强制 2 列 */ +@media (max-width: 600px) { .td-overview { grid-template-columns: repeat(2, 1fr); } } + +/* ═══════════════════════════════════════════════════════ + 上下文使用率环形图 + ═══════════════════════════════════════════════════════ */ +.td-ctx-ring-section { + margin-bottom: 20px; +} + +.td-ctx-ring-wrap { + background: var(--bg-card); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: 20px; + box-shadow: var(--shadow-card); +} + +.td-ctx-ring-container { + display: flex; + align-items: center; + gap: 28px; + flex-wrap: wrap; + justify-content: center; +} + +.td-ctx-ring-svg { + flex-shrink: 0; +} + +.td-ctx-ring-details { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 200px; +} + +.td-ctx-ring-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; +} + +.td-ctx-ring-dot { + width: 10px; + height: 10px; + border-radius: 2px; + flex-shrink: 0; +} + +.td-ctx-dot-used { + background: #9B7ED8; +} + +.td-ctx-dot-remain { + background: var(--border-subtle); +} + +.td-ctx-ring-label-text { + color: var(--text-secondary); + flex: 1; +} + +.td-ctx-ring-val { + font-family: var(--font-mono); + font-weight: 600; + color: var(--text-primary); +} + +.td-ctx-ring-total { + border-top: 1px solid var(--border-subtle); + padding-top: 8px; + margin-top: 2px; +} + +.td-ctx-ring-total .td-ctx-ring-val { + color: var(--accent); + font-weight: 700; +} + +.td-ctx-ring-note { + font-size: 11px; + color: var(--text-tertiary); + font-style: italic; + margin-top: 2px; +} + +.td-ctx-ring-source { + font-size: 10px; + color: var(--text-tertiary); + margin-top: 2px; + opacity: 0.8; +} + +.td-ctx-ring-streaming { + font-size: 11px; + color: #F0A040; + margin-top: 4px; + font-weight: 500; +} diff --git a/src/renderer/types.d.ts b/src/renderer/types.d.ts index 01a67f2..e8d3078 100644 --- a/src/renderer/types.d.ts +++ b/src/renderer/types.d.ts @@ -403,6 +403,12 @@ export interface LoopContext { planRetries: number; /** 循环开始时间 */ startTime: number; + /** 上一轮独立 stats(供 onNewIteration / makeStats 使用,避免累计值导致重复计算) */ + lastLoopStats?: { + eval_count?: number; + prompt_eval_count?: number; + total_duration?: number; + }; } /** Agent 运行模式 */