From e5224e4a8d92bd224b2faee847948a2e95a1c71f Mon Sep 17 00:00:00 2001
From: thzxx
Date: Fri, 10 Jul 2026 21:34:15 +0800
Subject: [PATCH] chore: bump version to 0.14.14
---
README.md | 6 +-
package-lock.json | 4 +-
package.json | 2 +-
src/main/db/sqlite.ts | 2 +-
src/main/menu.ts | 2 +-
src/renderer/api/ollama.ts | 7 +-
src/renderer/components/token-dashboard.ts | 131 ++++++++++++++++++---
src/renderer/db/chat-db.ts | 9 +-
src/renderer/index.html | 2 +-
src/renderer/services/agent-engine.ts | 75 ++++++++----
src/renderer/services/memory-service.ts | 70 +++++------
src/renderer/services/tool-registry.ts | 6 +-
src/renderer/styles/style.css | 3 +-
src/renderer/types.d.ts | 2 +
14 files changed, 218 insertions(+), 103 deletions(-)
diff --git a/README.md b/README.md
index 0faef20..da08276 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -253,7 +253,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
```
-产出:`release/Metona Ollama Setup v0.14.13.exe`
+产出:`release/Metona Ollama Setup v0.14.14.exe`
## 🛠️ 常用命令
@@ -501,7 +501,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
```
-Output: `release/Metona Ollama Setup v0.14.13.exe`
+Output: `release/Metona Ollama Setup v0.14.14.exe`
## 🛠️ Common Commands
diff --git a/package-lock.json b/package-lock.json
index 3b5f493..c2b1418 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "metona-ollama-desktop",
- "version": "0.14.13",
+ "version": "0.14.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "metona-ollama-desktop",
- "version": "0.14.13",
+ "version": "0.14.14",
"license": "MIT",
"dependencies": {
"ffmpeg-static": "^5.2.0",
diff --git a/package.json b/package.json
index 9f51d19..7aeb976 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "metona-ollama-desktop",
- "version": "0.14.13",
+ "version": "0.14.14",
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
"main": "dist/main/main.js",
"author": "thzxx",
diff --git a/src/main/db/sqlite.ts b/src/main/db/sqlite.ts
index 89b1c6b..e5ca1d6 100644
--- a/src/main/db/sqlite.ts
+++ b/src/main/db/sqlite.ts
@@ -404,7 +404,7 @@ export function getAllSessionsTokenStats(): AllSessionsTokenStats {
COALESCE(SUM(m.prompt_eval_count), 0) as total_input,
COALESCE(SUM(m.eval_count), 0) as total_output,
COALESCE(SUM(m.total_duration), 0) as total_duration,
- COUNT(CASE WHEN m.role = 'assistant' AND (m.eval_count > 0 OR m.prompt_eval_count > 0) THEN 1 END) as round_count
+ COUNT(CASE WHEN m.role = 'assistant' AND (m.eval_count IS NOT NULL OR m.prompt_eval_count IS NOT NULL OR m.total_duration IS NOT NULL) THEN 1 END) as round_count
FROM sessions s
LEFT JOIN messages m ON s.id = m.session_id
GROUP BY s.id
diff --git a/src/main/menu.ts b/src/main/menu.ts
index 0debb41..a372490 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.13',
+ message: 'Metona Ollama Desktop v0.14.14',
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
icon: getIconPath()
});
diff --git a/src/renderer/api/ollama.ts b/src/renderer/api/ollama.ts
index d01762a..e32e058 100644
--- a/src/renderer/api/ollama.ts
+++ b/src/renderer/api/ollama.ts
@@ -29,8 +29,9 @@ export class OllamaAPI {
return response;
}
- private async _json(path: string, body: unknown = null): Promise {
+ private async _json(path: string, body: unknown = null, signal?: AbortSignal): Promise {
const options: RequestInit = body ? { method: 'POST', body: JSON.stringify(body) } : {};
+ if (signal) options.signal = signal;
const response = await this._request(path, options);
return response.json() as Promise;
}
@@ -51,7 +52,7 @@ export class OllamaAPI {
return this._json('/api/show', { model });
}
- async chat(params: Omit): Promise {
+ async chat(params: Omit & { signal?: AbortSignal }): Promise {
const body: Record = {
model: params.model,
messages: params.messages,
@@ -62,7 +63,7 @@ export class OllamaAPI {
if (params.system) body.system = params.system;
if (params.keep_alive !== undefined) body.keep_alive = params.keep_alive;
if (params.options) body.options = params.options;
- return this._json('/api/chat', body);
+ return this._json('/api/chat', body, params.signal);
}
async chatStream(
diff --git a/src/renderer/components/token-dashboard.ts b/src/renderer/components/token-dashboard.ts
index d682fcb..4684bc0 100644
--- a/src/renderer/components/token-dashboard.ts
+++ b/src/renderer/components/token-dashboard.ts
@@ -112,25 +112,56 @@ interface RoundData {
total_duration: number;
timestamp: number;
model?: string;
+ llmCallCount: number;
}
+/**
+ * 收集轮次数据 — 按用户消息分组(一次用户消息 = 一轮)。
+ * Agent Loop 中一次用户交互可能触发多次 LLM 调用,
+ * 这些调用的 token 统计会被聚合到同一个轮次中。
+ */
function collectRoundData(messages: ChatMessage[]): RoundData[] {
const rounds: RoundData[] = [];
- let roundNum = 0;
+ let turnNum = 0;
+ let currentTurn: RoundData | null = null;
+
for (const msg of messages) {
- if (msg.role === 'assistant' && (msg.eval_count || msg.prompt_eval_count || msg.total_duration)) {
- roundNum++;
- rounds.push({
- round: roundNum,
- role: msg.role,
- eval_count: msg.eval_count || 0,
- prompt_eval_count: msg.prompt_eval_count || 0,
- total_duration: msg.total_duration || 0,
+ if (msg.role === 'user') {
+ // 新轮次开始
+ if (currentTurn) rounds.push(currentTurn);
+ turnNum++;
+ currentTurn = {
+ round: turnNum,
+ role: 'assistant',
+ eval_count: 0,
+ prompt_eval_count: 0,
+ total_duration: 0,
timestamp: msg.timestamp,
- model: msg.model,
- });
+ llmCallCount: 0,
+ };
+ } else if (msg.role === 'assistant' && (msg.eval_count || msg.prompt_eval_count || msg.total_duration)) {
+ if (!currentTurn) {
+ // 边界情况:assistant 消息出现在任何 user 消息之前
+ turnNum++;
+ currentTurn = {
+ round: turnNum,
+ role: 'assistant',
+ eval_count: 0,
+ prompt_eval_count: 0,
+ total_duration: 0,
+ timestamp: msg.timestamp,
+ llmCallCount: 0,
+ };
+ }
+ currentTurn.eval_count += msg.eval_count || 0;
+ currentTurn.prompt_eval_count += msg.prompt_eval_count || 0;
+ currentTurn.total_duration += msg.total_duration || 0;
+ currentTurn.llmCallCount++;
+ currentTurn.model = msg.model || currentTurn.model;
+ currentTurn.timestamp = msg.timestamp;
}
}
+ if (currentTurn) rounds.push(currentTurn);
return rounds;
}
@@ -184,9 +215,32 @@ function renderDashboard(): void {
}
}
+/** P9: 全局刷新请求 ID,防止异步竞态 */
+let globalFetchId = 0;
+
+/** 重新计算全局汇总 */
+function recalcTotals(sessions: SessionTokenStat[]): GlobalTokenTotals {
+ let totalInput = 0, totalOutput = 0, totalDuration = 0, totalRounds = 0;
+ for (const s of sessions) {
+ totalInput += s.total_input || 0;
+ totalOutput += s.total_output || 0;
+ totalDuration += s.total_duration || 0;
+ totalRounds += s.round_count || 0;
+ }
+ return {
+ total_input: totalInput,
+ total_output: totalOutput,
+ total_duration: totalDuration,
+ total_rounds: totalRounds,
+ session_count: sessions.length,
+ };
+}
+
/** 渲染全局统计视图 */
function renderGlobalView(): void {
+ const myFetchId = ++globalFetchId;
fetchGlobalStats().then(stats => {
+ if (myFetchId !== globalFetchId) return; // P9: 过期响应,丢弃
if (!stats) {
// P1 #3/#9: 刷新失败时保留旧数据,仅在从未加载成功时显示错误
if (hasEverLoaded && lastSuccessfulStats) {
@@ -198,6 +252,35 @@ function renderGlobalView(): void {
}
return;
}
+
+ // P2: 合并当前会话内存数据,确保实时性
+ const currentSession = state.get(KEYS.CURRENT_SESSION);
+ if (currentSession && currentSession.messages.length > 0) {
+ const rounds = collectRoundData(currentSession.messages);
+ if (rounds.length > 0) {
+ const totalInput = rounds.reduce((sum, r) => sum + r.prompt_eval_count, 0);
+ const totalOutput = rounds.reduce((sum, r) => sum + r.eval_count, 0);
+ const totalDuration = rounds.reduce((sum, r) => sum + r.total_duration, 0);
+ const sessionStat: SessionTokenStat = {
+ session_id: currentSession.id,
+ title: currentSession.title,
+ model: currentSession.model,
+ created_at: currentSession.createdAt,
+ total_input: totalInput,
+ total_output: totalOutput,
+ total_duration: totalDuration,
+ round_count: rounds.length,
+ };
+ const idx = stats.sessions.findIndex(s => s.session_id === currentSession.id);
+ if (idx >= 0) {
+ stats.sessions[idx] = sessionStat;
+ } else {
+ stats.sessions.push(sessionStat);
+ }
+ stats.totals = recalcTotals(stats.sessions);
+ }
+ }
+
renderGlobalOverview(stats);
renderGlobalChart(stats);
renderGlobalTable(stats);
@@ -294,8 +377,8 @@ function renderBarChart(
- ${formatTokenCount(d.output)}
${formatTokenCount(d.input)}
+ ${formatTokenCount(d.output)}
${escapeHtml(d.label)}
@@ -346,7 +429,10 @@ 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);
+ const noteText = sessions.length > sorted.length
+ ? `最近 ${sorted.length} / 共 ${sessions.length} 个会话`
+ : `共 ${sorted.length} 个会话`;
+ renderBarChart(chartEl, chartData, noteText, savedScrollLeft);
}
/** 渲染全局明细表格 */
@@ -584,17 +670,26 @@ function renderSessionView(): void {
const barsEl = chartEl.querySelector('.td-chart-bars') as HTMLElement | null;
const savedScrollLeft = barsEl?.scrollLeft ?? 0;
- const chartData = rounds.map(r => ({
+ // P4: 限制图表柱子数量,避免长会话性能下降
+ const MAX_CHART_ROUNDS = 50;
+ const displayRounds = rounds.length > MAX_CHART_ROUNDS ? rounds.slice(-MAX_CHART_ROUNDS) : rounds;
+
+ const chartData = displayRounds.map(r => ({
label: `R${r.round}`,
input: r.prompt_eval_count,
output: r.eval_count,
- tooltip: `第${r.round}轮 · 输入 ${formatTokenCount(r.prompt_eval_count)} + 输出 ${formatTokenCount(r.eval_count)} = ${formatTokenCount(r.eval_count + r.prompt_eval_count)} tokens · ${formatDuration(r.total_duration)}`,
+ tooltip: `第${r.round}轮${r.llmCallCount > 1 ? ` · ${r.llmCallCount}次调用` : ''} · 输入 ${formatTokenCount(r.prompt_eval_count)} + 输出 ${formatTokenCount(r.eval_count)} = ${formatTokenCount(r.eval_count + r.prompt_eval_count)} tokens · ${formatDuration(r.total_duration)}`,
}));
const chartTitleEl = dashboardModalEl.querySelector('#tdChartTitle');
if (chartTitleEl) chartTitleEl.textContent = '📈 Token 消耗趋势 — 按轮次';
- renderBarChart(chartEl, chartData, undefined, savedScrollLeft);
+ // P4 #6/#7: 图表 note — 显示轮次总数 + 流式提示
+ const noteParts: string[] = [];
+ if (rounds.length > MAX_CHART_ROUNDS) noteParts.push(`最近 ${MAX_CHART_ROUNDS} / 共 ${rounds.length} 轮`);
+ else noteParts.push(`共 ${rounds.length} 轮`);
+ if (isStreaming) noteParts.push('⚡ AI 回复中…');
+ renderBarChart(chartEl, chartData, noteParts.join(' · '), savedScrollLeft);
// 表格标题
const tableTitleEl = dashboardModalEl.querySelector('#tdTableTitle') as HTMLElement | null;
@@ -625,7 +720,7 @@ function renderSessionView(): void {
return `
| R${r.round} |
- 🤖 AI${r.model ? ` ${escapeHtml(r.model)}` : ''} |
+ ${r.llmCallCount > 1 ? `${r.llmCallCount}次` : '1次'}${r.model ? ` ${escapeHtml(r.model)}` : ''} |
${formatTokenCount(r.prompt_eval_count)} |
${formatTokenCount(r.eval_count)} |
${formatTokenCount(total)} |
@@ -661,7 +756,7 @@ function updateTableHeader(mode: 'global' | 'session'): void {
} else {
thead.innerHTML = `
轮次 |
- 角色 |
+ 调用 |
输入 |
输出 |
合计 |
diff --git a/src/renderer/db/chat-db.ts b/src/renderer/db/chat-db.ts
index 2d2b6c4..29a8ffe 100644
--- a/src/renderer/db/chat-db.ts
+++ b/src/renderer/db/chat-db.ts
@@ -83,9 +83,10 @@ export class ChatDB {
};
await dbBridge().saveSession(row);
// 同步保存消息
- for (const msg of session.messages) {
+ for (let mi = 0; mi < session.messages.length; mi++) {
+ const msg = session.messages[mi];
const msgRow = {
- id: `${session.id}_${msg.timestamp}_${msg.role}`,
+ id: `${session.id}_${msg.timestamp}_${msg.role}_${mi}`,
session_id: session.id,
role: msg.role,
content: msg.content || null,
@@ -176,8 +177,8 @@ export class ChatDB {
id: s.id, title: s.title, model: s.model, system_prompt: null,
parent_id: null, status: 'active', created_at: s.createdAt, updated_at: s.updatedAt
})),
- messages: sessions.flatMap(s => s.messages.map(m => ({
- id: `${s.id}_${m.timestamp}_${m.role}`,
+ messages: sessions.flatMap(s => s.messages.map((m, mi) => ({
+ id: `${s.id}_${m.timestamp}_${m.role}_${mi}`,
session_id: s.id, role: m.role, content: m.content || null,
thinking: m.think || null,
images: m.images?.length ? JSON.stringify(m.images) : null,
diff --git a/src/renderer/index.html b/src/renderer/index.html
index e367777..de484b6 100644
--- a/src/renderer/index.html
+++ b/src/renderer/index.html
@@ -28,7 +28,7 @@