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 @@

- 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.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 @@
Metona Ollama - v0.14.13 + v0.14.14