From a4b82b45bcad5aeeee914b23ba3b298b01ca5afb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=B4=AB=E5=BD=B1233?=
Date: Thu, 16 Jul 2026 15:53:28 +0800
Subject: [PATCH] =?UTF-8?q?v0.16.9:=20=E6=A0=B8=E5=BF=83=E5=BC=95=E6=93=8E?=
=?UTF-8?q?=E6=B7=B1=E5=BA=A6=E5=88=86=E6=9E=90=E4=BF=AE=E5=A4=8D=20?=
=?UTF-8?q?=E2=80=94=2027=20=E4=B8=AA=E9=97=AE=E9=A2=98=EF=BC=88P0=C3=978?=
=?UTF-8?q?=20+=20P1=C3=9719=20+=20P2=20=E6=AD=BB=E4=BB=A3=E7=A0=81?=
=?UTF-8?q?=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
引擎层(agent-engine.ts):
- P0-R1: handleExecuting 中止改 throw AbortError,让主循环 catch 统一处理 UI 反馈
- P0-E1: executeToolWithTimeout 注释修正,明确 fire-and-forget 限制
- P0-P3: persistLoopContext 改名 snapshotLoopContext + 补全字段,明确语义为运行时快照
- P1-E1: handleInit 增加 isAborted 检查(loadCustomFiles 和 search 后)
- P1-E2: catch 块调用 snapshotLoopContext 确保终止状态写入
- P1-E3: onConfirmTool await 后检查中止信号
- P1-E4: flushAllTraces 改为 await
- P1-E5: 死锁检测 ephemeral 消息加入 PRESERVE_PATTERNS
- P1-E6: 记忆提取 setTimeout 存储到 _pendingMemoryTimers,finally 块清理
- P1-R5: handleExecuting 中止时设置 _abortToolRecords
上下文层(context-manager.ts + agent-engine.ts):
- P0-C1: 删除 nonSystemCompressed,旧摘要不再保留(已合并到新摘要)
- P0-C2: 部分删除 tool 消息时也 strip tool_calls,降级为纯文本 assistant
- P1-C1: handleCompressing 移除 shouldAutoCompress 二次判断,直接执行压缩
- P1-C2: COMPRESSING 状态增加中止检查
- P1-C3: R15 tail 边界保护 middle 无 assistant 时移除孤立 tool 消息
- P1-C4: 压缩接受条件改 AND(3 处:handleInit/R8/handleCompressing)
- P1-C6: head 中已压缩消息的 tool_calls 清除,避免累积膨胀
渲染层(chat-area.ts + input-area.ts + workspace-panel.ts):
- P0-R2: appendAssistantPlaceholder 重置 _streamLastRenderedLen/_streamLastRenderedHash/_streamRenderContent
- P1-R1: rAF 竞态通过重置 _streamRenderContent 解决
- P1-R2: clearMessages/clearMessagesDOM 重置 _sysPromptRendered
- P1-R3: onNewIteration 渲染顺序修正(remove → render → append)
- P1-R4: updateToolCardDOM 改用 querySelectorAll 取最后一个匹配
持久化层(chat-db.ts + sqlite.ts + ipc.ts + history-modal.ts):
- P0-P1: onNewIteration 调用 saveCurrentSession 持久化中间消息
- P0-P2: 非 AbortError 异常时保存 partial 消息(标记 interrupted: true)
- P1-P1: saveSession 增量保存(_savedMsgIds Set 追踪),resetSavedMsgTracking
- P1-P2: 新增 saveSettingsBatch 批量写盘接口 + IPC handler
- P1-P3: 删除死表 tool_calls 的 saveToolCall/getToolCallsBySession 函数和 IPC handler
死代码清理:
- chat-area.ts: 删除 appendToolCallCardToPlaceholder/updateToolCallCardInPlaceholder(从未调用)
- chat-area.ts: 删除 _streamRenderThink/_streamRenderModel 未使用变量
- input-area.ts: 删除 updateMessageToolRecord 空函数 + 对应 import
版本号 0.16.8 → 0.16.9
---
README.md | 6 +-
package-lock.json | 4 +-
package.json | 2 +-
src/main/db/sqlite.ts | 28 +++---
src/main/ipc.ts | 18 ++--
src/main/menu.ts | 2 +-
src/renderer/components/chat-area.ts | 53 +++--------
src/renderer/components/history-modal.ts | 2 +
src/renderer/components/input-area.ts | 28 +++++-
src/renderer/components/workspace-panel.ts | 14 ++-
src/renderer/db/chat-db.ts | 15 ++-
src/renderer/index.html | 2 +-
src/renderer/services/agent-engine.ts | 105 +++++++++++++++++----
src/renderer/services/context-manager.ts | 34 ++++++-
14 files changed, 207 insertions(+), 106 deletions(-)
diff --git a/README.md b/README.md
index 2623f0a..ef61a5a 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.16.8.exe`
+产出:`release/Metona Ollama Setup v0.16.9.exe`
## 🛠️ 常用命令
@@ -501,7 +501,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
```
-Output: `release/Metona Ollama Setup v0.16.8.exe`
+Output: `release/Metona Ollama Setup v0.16.9.exe`
## 🛠️ Common Commands
diff --git a/package-lock.json b/package-lock.json
index 3f6c37b..55aaeae 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "metona-ollama-desktop",
- "version": "0.16.8",
+ "version": "0.16.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "metona-ollama-desktop",
- "version": "0.16.8",
+ "version": "0.16.9",
"license": "MIT",
"dependencies": {
"ffmpeg-static": "^5.2.0",
diff --git a/package.json b/package.json
index bcb1e4f..3cf76c6 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "metona-ollama-desktop",
- "version": "0.16.8",
+ "version": "0.16.9",
"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 17ba5a4..83d54e0 100644
--- a/src/main/db/sqlite.ts
+++ b/src/main/db/sqlite.ts
@@ -327,6 +327,17 @@ export function saveSetting(key: string, value: unknown): void {
persist();
}
+/** P1-P2 修复:批量保存设置,只触发一次 persist,避免每个 setting 都全库写盘 */
+export function saveSettingsBatch(entries: Array<{ key: string; value: unknown }>): void {
+ const d = getDb();
+ for (const { key, value } of entries) {
+ runExec(d, 'INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)',
+ [key, JSON.stringify(value), Date.now()]
+ );
+ }
+ persist(); // 只写盘一次
+}
+
export function getSetting(key: string, defaultValue: T | null = null): T {
const row = queryOne(getDb(), 'SELECT value FROM settings WHERE key = ?', [key]) as { value: string } | null;
if (!row) return defaultValue as T;
@@ -338,19 +349,10 @@ export function getSetting(key: string, defaultValue: T | null = nu
}
// ─── Tool Calls CRUD ───
-
-export function saveToolCall(tc: ToolCallRow): string {
- runExec(getDb(), `INSERT OR REPLACE INTO tool_calls (id, message_id, session_id, tool_name, arguments, result, status, duration_ms, created_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- [tc.id, tc.message_id, tc.session_id, tc.tool_name, tc.arguments, tc.result, tc.status, tc.duration_ms, tc.created_at]
- );
- persist();
- return tc.id;
-}
-
-export function getToolCallsBySession(sessionId: string): ToolCallRow[] {
- return queryAll(getDb(), 'SELECT * FROM tool_calls WHERE session_id = ? ORDER BY created_at ASC', [sessionId]) as unknown as ToolCallRow[];
-}
+// P1-P3 修复:saveToolCall 和 getToolCallsBySession 是死代码(渲染进程从未调用)。
+// tool_calls 表保留(已有数据库可能包含此表,删除表定义会导致升级时报错),
+// 但删除未使用的函数和 IPC handler。
+// 工具调用记录实际通过 messages.tool_calls JSON 列存储。
// ─── Traces CRUD ───
diff --git a/src/main/ipc.ts b/src/main/ipc.ts
index b9988a3..bcb1c4d 100644
--- a/src/main/ipc.ts
+++ b/src/main/ipc.ts
@@ -11,8 +11,7 @@ import { showNotification } from './utils.js';
import {
initDatabase, saveSession, getSession, getAllSessions, deleteSession, clearAllSessions,
saveMessage, getMessagesBySession,
- saveSetting, getSetting,
- saveToolCall, getToolCallsBySession,
+ saveSetting, getSetting, saveSettingsBatch,
saveTrace, getTracesBySession,
exportAllSessions, importSessions,
getAllSessionsTokenStats
@@ -391,20 +390,17 @@ export async function setupIPC(): Promise {
try { saveSetting(key, value); return { success: true }; }
catch (err) { return { success: false, error: (err as Error).message }; }
});
+ // P1-P2 修复:批量保存设置,只触发一次 persist
+ ipcMain.handle('db:saveSettingsBatch', (_, entries: Array<{ key: string; value: unknown }>) => {
+ try { saveSettingsBatch(entries); return { success: true }; }
+ catch (err) { return { success: false, error: (err as Error).message }; }
+ });
ipcMain.handle('db:getSetting', (_, key: string, defaultValue?: unknown) => {
try { return getSetting(key, defaultValue ?? null); }
catch { return defaultValue ?? null; }
});
- // Tool Calls
- ipcMain.handle('db:saveToolCall', (_, tc) => {
- try { return { success: true, id: saveToolCall(tc) }; }
- catch (err) { return { success: false, error: (err as Error).message }; }
- });
- ipcMain.handle('db:getToolCalls', (_, sessionId) => {
- try { return getToolCallsBySession(sessionId); }
- catch { return []; }
- });
+ // Tool Calls — P1-P3 修复:删除死代码(渲染进程从未调用,工具记录通过 messages.tool_calls 存储)
// Traces
ipcMain.handle('db:saveTrace', (_, trace) => {
diff --git a/src/main/menu.ts b/src/main/menu.ts
index e8c2706..0fb03c6 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.16.8',
+ message: 'Metona Ollama Desktop v0.16.9',
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
icon: getIconPath()
});
diff --git a/src/renderer/components/chat-area.ts b/src/renderer/components/chat-area.ts
index 525208c..7eb463e 100644
--- a/src/renderer/components/chat-area.ts
+++ b/src/renderer/components/chat-area.ts
@@ -376,8 +376,6 @@ function patchIncompleteMarkdown(md: string): string {
// ── 流式渲染节流状态 ──
let _streamRenderPending = false;
let _streamRenderContent = '';
-let _streamRenderThink: string | null = null;
-let _streamRenderModel: string | undefined;
// R58: 流式渲染优化 — 增量阈值 + 内容指纹,避免高频小片段冗余重渲染
let _streamLastRenderedLen = 0; // 上次渲染时的内容长度
let _streamLastRenderedHash = 0; // 上次渲染时的内容哈希(简单 DJB2)
@@ -400,6 +398,9 @@ function _doStreamRender(): void {
_streamRenderPending = false;
const lastMsg = currentPlaceholder;
if (!lastMsg) return;
+ // P1-R1 修复:rAF 回调执行时 currentPlaceholder 可能已被 appendAssistantPlaceholder 替换为新节点,
+ // 且 _streamRenderContent 已被重置为 '',此时不会误渲染。appendAssistantPlaceholder 中的
+ // _streamRenderContent = '' 重置确保了旧 rAF 回调拿到的是空内容,不会渲染到新 placeholder。
// R58: 增量阈值检查 — 内容变化不足时跳过本次渲染
const contentLen = _streamRenderContent.length;
@@ -551,44 +552,6 @@ export function updateLastAssistantMessage(
scrollToBottom();
}
-/** 实时追加工具调用卡片到当前 placeholder(直播聊天时使用) */
-export function appendToolCallCardToPlaceholder(tc: ToolCallRecord): void {
- if (!currentPlaceholder) return;
- let container = currentPlaceholder.querySelector('.tool-calls-container');
- if (!container) {
- container = document.createElement('div');
- container.className = 'tool-calls-container';
- const msgBody = currentPlaceholder.querySelector('.msg-body');
- if (msgBody) msgBody.appendChild(container);
- }
- const cardDiv = document.createElement('div');
- cardDiv.innerHTML = renderToolCallCard(tc);
- const card = cardDiv.firstElementChild as HTMLElement;
- card.dataset.toolName = tc.name;
- container.appendChild(card);
- scrollToBottom();
-}
-
-/** 更新 placeholder 中已有的工具卡片状态(running → success/error) */
-export function updateToolCallCardInPlaceholder(tc: ToolCallRecord): void {
- if (!currentPlaceholder) return;
- const container = currentPlaceholder.querySelector('.tool-calls-container');
- if (!container) return;
- // 找到第一个同名且状态为 running 的卡片(CSS.escape 防特殊字符)
- const escapedName = CSS.escape(tc.name);
- const existing = container.querySelector(`.tool-call-card[data-tool-name="${escapedName}"].tool-call-running`) as HTMLElement;
- if (existing) {
- const cardDiv = document.createElement('div');
- cardDiv.innerHTML = renderToolCallCard(tc);
- const newCard = cardDiv.firstElementChild as HTMLElement;
- newCard.dataset.toolName = tc.name;
- existing.replaceWith(newCard);
- } else {
- // 没找到 running 的卡片,直接追加
- appendToolCallCardToPlaceholder(tc);
- }
-}
-
export function appendAssistantPlaceholder(): void {
const div = document.createElement('div');
div.className = 'message assistant loading';
@@ -606,6 +569,12 @@ export function appendAssistantPlaceholder(): void {
messagesContainerEl.appendChild(div);
currentPlaceholder = div;
+ // P0-R2 修复:新 placeholder 必须重置流式渲染状态,否则上一条的 _streamLastRenderedLen 会压制新消息渲染
+ _streamLastRenderedLen = 0;
+ _streamLastRenderedHash = 0;
+ _streamRenderContent = '';
+ _streamRenderPending = false;
+
// R34: 启动旋转提示
startLoadingHintRotation(div);
@@ -654,12 +623,16 @@ export function appendSystemMessage(text: string, tempClass?: string): void {
export function clearMessagesDOM(): void {
messagesContainerEl.innerHTML = '';
_renderedMsgIndices.clear(); // P1-1: 重置 diff 状态
+ // P1-R2 修复:重置系统提示词标记,否则清空后首条消息不再显示系统卡片
+ _sysPromptRendered = false;
}
export function clearMessages(): void {
messagesContainerEl.innerHTML = '';
currentPlaceholder = null;
_renderedMsgIndices.clear(); // P1-1: 重置 diff 状态
+ // P1-R2 修复:与 clearMessagesDOM 保持一致
+ _sysPromptRendered = false;
}
/** 清理 currentPlaceholder 引用(当 placeholder 从外部被移除时调用) */
diff --git a/src/renderer/components/history-modal.ts b/src/renderer/components/history-modal.ts
index bc811a8..e7e0de6 100644
--- a/src/renderer/components/history-modal.ts
+++ b/src/renderer/components/history-modal.ts
@@ -177,6 +177,8 @@ async function loadHistorySession(sessionId: string): Promise {
logSession('加载', `${session.title} (${session.messages.length}条消息)`);
state.set(KEYS.CURRENT_SESSION, session);
state.set(KEYS.IS_HISTORY_VIEW, true);
+ // P1-P1 修复:切换会话时重置已保存消息追踪
+ db.resetSavedMsgTracking();
(document.querySelector('#historyBar') as HTMLElement).style.display = '';
(document.querySelector('#inputArea') as HTMLElement).style.display = 'none';
diff --git a/src/renderer/components/input-area.ts b/src/renderer/components/input-area.ts
index 3373c54..122197b 100644
--- a/src/renderer/components/input-area.ts
+++ b/src/renderer/components/input-area.ts
@@ -9,7 +9,7 @@ import { getSelectedModel, isThinkEnabled, isVisionAvailable, isToolCallingSuppo
import {
renderMessages, clearMessagesDOM, appendAssistantPlaceholder, appendSystemMessage,
updateLastAssistantMessage, clearMessages, safeMarkdown, enableAutoScroll, resetAutoScroll,
- appendToolCallCardToPlaceholder, updateToolCallCardInPlaceholder, resetCurrentPlaceholder, removeCurrentPlaceholder
+ resetCurrentPlaceholder, removeCurrentPlaceholder
} from './chat-area.js';
import { showToast } from './toast.js';
import { addToolCard, startToolCard, updateToolCard, clearToolCardsExternal, clearTerminalExternal, getWorkspaceDirPath, hasActiveCards, showWorkingHint, clearWorkingHint } from './workspace-panel.js';
@@ -1098,10 +1098,6 @@ export async function sendMessage(): Promise {
return;
}
-/** 更新当前会话消息中最近一个 'running' 状态的同名工具记录 */
-function updateMessageToolRecord(toolName: string, status: 'success' | 'error', result: Record): void {
- // 工具卡片统一由 onDone 挂载,不在执行中更新消息记录
-}
async function sendMessageWithAgentLoop(text: string, currentSession: ChatSession, model: string): Promise {
const now = Date.now();
@@ -1286,6 +1282,9 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
messages: [...session.messages, prevMsg],
updatedAt: Date.now()
}));
+ // P0-P1 修复:中间迭代消息必须持久化,否则崩溃时前几轮的 assistant 消息全丢失
+ saveCurrentSession().catch(e => logError('onNewIteration 保存失败', String(e)));
+ // P1-R3 修复:先移除旧 placeholder → 渲染历史 → 创建新 placeholder,避免渲染空隙
removeCurrentPlaceholder();
renderMessages();
appendAssistantPlaceholder();
@@ -1433,6 +1432,25 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
if ((err as Error).message.includes('Failed to fetch') || (err as Error).message.includes('NetworkError')) {
errMsg = '❌ 连接失败。请检查 Ollama 是否正在运行';
}
+ // P0-P2 修复:非 AbortError 异常时也保存已生成的 partial 消息,避免网络断开/Ollama 崩溃时内容丢失
+ if (assistantContent) {
+ const partialMsg: ChatMessage = {
+ role: 'assistant',
+ content: assistantContent,
+ model: getSelectedModel(),
+ timestamp: Date.now(),
+ ...(thinkContent && { think: thinkContent }),
+ ...(currentIterationToolRecords.length > 0 && { toolCalls: [...currentIterationToolRecords] }),
+ interrupted: true,
+ };
+ currentIterationToolRecords = [];
+ state.update(KEYS.CURRENT_SESSION, (session: any) => ({
+ ...session,
+ messages: [...session.messages, partialMsg],
+ updatedAt: Date.now()
+ }));
+ await saveCurrentSession();
+ }
appendSystemMessage(errMsg);
}
} finally {
diff --git a/src/renderer/components/workspace-panel.ts b/src/renderer/components/workspace-panel.ts
index a338d64..d4d6e98 100644
--- a/src/renderer/components/workspace-panel.ts
+++ b/src/renderer/components/workspace-panel.ts
@@ -878,11 +878,15 @@ function updateToolCardDOM(tc: ToolCallRecord): void {
const container = document.querySelector('#wsToolsOutput') as HTMLElement;
if (!container) return;
- // 查找同名且状态为 pending 或 running 的卡片 DOM 节点
- const runningCard = (
- container.querySelector(`.tool-call-card[data-tool-name="${escapeAttr(tc.name)}"].tool-call-running`) ||
- container.querySelector(`.tool-call-card[data-tool-name="${escapeAttr(tc.name)}"].tool-call-pending`)
- ) as HTMLElement | null;
+ // P1-R4 修复:同名工具并发执行时 querySelector 只返回第一个匹配,可能更新到错误的卡片。
+ // 改为 querySelectorAll 查找所有同名 pending/running 卡片,取最后一个(最新的那个)更新。
+ // 这与 updateToolCard 函数(行 839-853)在 JS 数组层面反向遍历找最后一个 running/pending 的逻辑一致。
+ const escapedName = escapeAttr(tc.name);
+ const allRunningCards = Array.from(container.querySelectorAll(
+ `.tool-call-card[data-tool-name="${escapedName}"].tool-call-running,` +
+ `.tool-call-card[data-tool-name="${escapedName}"].tool-call-pending`
+ )) as HTMLElement[];
+ const runningCard = allRunningCards.length > 0 ? allRunningCards[allRunningCards.length - 1] : null;
if (runningCard) {
const wrapper = document.createElement('div');
diff --git a/src/renderer/db/chat-db.ts b/src/renderer/db/chat-db.ts
index 29a8ffe..321ab9e 100644
--- a/src/renderer/db/chat-db.ts
+++ b/src/renderer/db/chat-db.ts
@@ -69,6 +69,9 @@ export class ChatDB {
// ── Sessions ──
+ // P1-P1 修复:追踪已保存的消息 ID,避免每次 saveSession 全量重写所有消息
+ private _savedMsgIds = new Set();
+
async saveSession(session: ChatSession): Promise {
if (isDesktop()) {
const row = {
@@ -82,11 +85,14 @@ export class ChatDB {
updated_at: session.updatedAt
};
await dbBridge().saveSession(row);
- // 同步保存消息
+ // P1-P1 修复:只保存新增消息(不在 _savedMsgIds 中的),避免全量重写
for (let mi = 0; mi < session.messages.length; mi++) {
const msg = session.messages[mi];
+ const msgId = `${session.id}_${msg.timestamp}_${msg.role}_${mi}`;
+ if (this._savedMsgIds.has(msgId)) continue; // 已保存,跳过
+ this._savedMsgIds.add(msgId);
const msgRow = {
- id: `${session.id}_${msg.timestamp}_${msg.role}_${mi}`,
+ id: msgId,
session_id: session.id,
role: msg.role,
content: msg.content || null,
@@ -107,6 +113,11 @@ export class ChatDB {
return this._idbSaveSession(session);
}
+ /** P1-P1 修复:切换会话时重置已保存消息追踪,释放内存 */
+ resetSavedMsgTracking(): void {
+ this._savedMsgIds.clear();
+ }
+
async getSession(id: string): Promise {
if (isDesktop()) {
const row = await dbBridge().getSession(id);
diff --git a/src/renderer/index.html b/src/renderer/index.html
index 70915b2..b9100fa 100644
--- a/src/renderer/index.html
+++ b/src/renderer/index.html
@@ -28,7 +28,7 @@