v0.16.9: 核心引擎深度分析修复 — 27 个问题(P0×8 + P1×19 + P2 死代码)

引擎层(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
This commit is contained in:
紫影233
2026-07-16 15:53:28 +08:00
parent 2ac07208b5
commit a4b82b45bc
14 changed files with 207 additions and 106 deletions
+3 -3
View File
@@ -14,7 +14,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/version-v0.16.8-E8734A?style=flat-square" alt="version"> <img src="https://img.shields.io/badge/version-v0.16.9-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.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 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 ## 🛠️ Common Commands
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "0.16.8", "version": "0.16.9",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "0.16.8", "version": "0.16.9",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ffmpeg-static": "^5.2.0", "ffmpeg-static": "^5.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "0.16.8", "version": "0.16.9",
"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",
+15 -13
View File
@@ -327,6 +327,17 @@ export function saveSetting(key: string, value: unknown): void {
persist(); 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<T = unknown>(key: string, defaultValue: T | null = null): T { export function getSetting<T = unknown>(key: string, defaultValue: T | null = null): T {
const row = queryOne(getDb(), 'SELECT value FROM settings WHERE key = ?', [key]) as { value: string } | null; const row = queryOne(getDb(), 'SELECT value FROM settings WHERE key = ?', [key]) as { value: string } | null;
if (!row) return defaultValue as T; if (!row) return defaultValue as T;
@@ -338,19 +349,10 @@ export function getSetting<T = unknown>(key: string, defaultValue: T | null = nu
} }
// ─── Tool Calls CRUD ─── // ─── Tool Calls CRUD ───
// P1-P3 修复:saveToolCall 和 getToolCallsBySession 是死代码(渲染进程从未调用)。
export function saveToolCall(tc: ToolCallRow): string { // tool_calls 表保留(已有数据库可能包含此表,删除表定义会导致升级时报错),
runExec(getDb(), `INSERT OR REPLACE INTO tool_calls (id, message_id, session_id, tool_name, arguments, result, status, duration_ms, created_at) // 但删除未使用的函数和 IPC handler。
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, // 工具调用记录实际通过 messages.tool_calls JSON 列存储。
[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[];
}
// ─── Traces CRUD ─── // ─── Traces CRUD ───
+7 -11
View File
@@ -11,8 +11,7 @@ import { showNotification } from './utils.js';
import { import {
initDatabase, saveSession, getSession, getAllSessions, deleteSession, clearAllSessions, initDatabase, saveSession, getSession, getAllSessions, deleteSession, clearAllSessions,
saveMessage, getMessagesBySession, saveMessage, getMessagesBySession,
saveSetting, getSetting, saveSetting, getSetting, saveSettingsBatch,
saveToolCall, getToolCallsBySession,
saveTrace, getTracesBySession, saveTrace, getTracesBySession,
exportAllSessions, importSessions, exportAllSessions, importSessions,
getAllSessionsTokenStats getAllSessionsTokenStats
@@ -391,20 +390,17 @@ export async function setupIPC(): Promise<void> {
try { saveSetting(key, value); return { success: true }; } try { saveSetting(key, value); return { success: true }; }
catch (err) { return { success: false, error: (err as Error).message }; } 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) => { ipcMain.handle('db:getSetting', (_, key: string, defaultValue?: unknown) => {
try { return getSetting(key, defaultValue ?? null); } try { return getSetting(key, defaultValue ?? null); }
catch { return defaultValue ?? null; } catch { return defaultValue ?? null; }
}); });
// Tool Calls // Tool Calls — P1-P3 修复:删除死代码(渲染进程从未调用,工具记录通过 messages.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 []; }
});
// Traces // Traces
ipcMain.handle('db:saveTrace', (_, trace) => { ipcMain.handle('db:saveTrace', (_, trace) => {
+1 -1
View File
@@ -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.16.8', message: 'Metona Ollama Desktop v0.16.9',
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()
}); });
+13 -40
View File
@@ -376,8 +376,6 @@ function patchIncompleteMarkdown(md: string): string {
// ── 流式渲染节流状态 ── // ── 流式渲染节流状态 ──
let _streamRenderPending = false; let _streamRenderPending = false;
let _streamRenderContent = ''; let _streamRenderContent = '';
let _streamRenderThink: string | null = null;
let _streamRenderModel: string | undefined;
// R58: 流式渲染优化 — 增量阈值 + 内容指纹,避免高频小片段冗余重渲染 // R58: 流式渲染优化 — 增量阈值 + 内容指纹,避免高频小片段冗余重渲染
let _streamLastRenderedLen = 0; // 上次渲染时的内容长度 let _streamLastRenderedLen = 0; // 上次渲染时的内容长度
let _streamLastRenderedHash = 0; // 上次渲染时的内容哈希(简单 DJB2) let _streamLastRenderedHash = 0; // 上次渲染时的内容哈希(简单 DJB2)
@@ -400,6 +398,9 @@ function _doStreamRender(): void {
_streamRenderPending = false; _streamRenderPending = false;
const lastMsg = currentPlaceholder; const lastMsg = currentPlaceholder;
if (!lastMsg) return; if (!lastMsg) return;
// P1-R1 修复:rAF 回调执行时 currentPlaceholder 可能已被 appendAssistantPlaceholder 替换为新节点,
// 且 _streamRenderContent 已被重置为 '',此时不会误渲染。appendAssistantPlaceholder 中的
// _streamRenderContent = '' 重置确保了旧 rAF 回调拿到的是空内容,不会渲染到新 placeholder。
// R58: 增量阈值检查 — 内容变化不足时跳过本次渲染 // R58: 增量阈值检查 — 内容变化不足时跳过本次渲染
const contentLen = _streamRenderContent.length; const contentLen = _streamRenderContent.length;
@@ -551,44 +552,6 @@ export function updateLastAssistantMessage(
scrollToBottom(); 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 { export function appendAssistantPlaceholder(): void {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'message assistant loading'; div.className = 'message assistant loading';
@@ -606,6 +569,12 @@ export function appendAssistantPlaceholder(): void {
messagesContainerEl.appendChild(div); messagesContainerEl.appendChild(div);
currentPlaceholder = div; currentPlaceholder = div;
// P0-R2 修复:新 placeholder 必须重置流式渲染状态,否则上一条的 _streamLastRenderedLen 会压制新消息渲染
_streamLastRenderedLen = 0;
_streamLastRenderedHash = 0;
_streamRenderContent = '';
_streamRenderPending = false;
// R34: 启动旋转提示 // R34: 启动旋转提示
startLoadingHintRotation(div); startLoadingHintRotation(div);
@@ -654,12 +623,16 @@ export function appendSystemMessage(text: string, tempClass?: string): void {
export function clearMessagesDOM(): void { export function clearMessagesDOM(): void {
messagesContainerEl.innerHTML = ''; messagesContainerEl.innerHTML = '';
_renderedMsgIndices.clear(); // P1-1: 重置 diff 状态 _renderedMsgIndices.clear(); // P1-1: 重置 diff 状态
// P1-R2 修复:重置系统提示词标记,否则清空后首条消息不再显示系统卡片
_sysPromptRendered = false;
} }
export function clearMessages(): void { export function clearMessages(): void {
messagesContainerEl.innerHTML = ''; messagesContainerEl.innerHTML = '';
currentPlaceholder = null; currentPlaceholder = null;
_renderedMsgIndices.clear(); // P1-1: 重置 diff 状态 _renderedMsgIndices.clear(); // P1-1: 重置 diff 状态
// P1-R2 修复:与 clearMessagesDOM 保持一致
_sysPromptRendered = false;
} }
/** 清理 currentPlaceholder 引用(当 placeholder 从外部被移除时调用) */ /** 清理 currentPlaceholder 引用(当 placeholder 从外部被移除时调用) */
+2
View File
@@ -177,6 +177,8 @@ async function loadHistorySession(sessionId: string): Promise<void> {
logSession('加载', `${session.title} (${session.messages.length}条消息)`); logSession('加载', `${session.title} (${session.messages.length}条消息)`);
state.set(KEYS.CURRENT_SESSION, session); state.set(KEYS.CURRENT_SESSION, session);
state.set(KEYS.IS_HISTORY_VIEW, true); state.set(KEYS.IS_HISTORY_VIEW, true);
// P1-P1 修复:切换会话时重置已保存消息追踪
db.resetSavedMsgTracking();
(document.querySelector('#historyBar') as HTMLElement).style.display = ''; (document.querySelector('#historyBar') as HTMLElement).style.display = '';
(document.querySelector('#inputArea') as HTMLElement).style.display = 'none'; (document.querySelector('#inputArea') as HTMLElement).style.display = 'none';
+23 -5
View File
@@ -9,7 +9,7 @@ import { getSelectedModel, isThinkEnabled, isVisionAvailable, isToolCallingSuppo
import { import {
renderMessages, clearMessagesDOM, appendAssistantPlaceholder, appendSystemMessage, renderMessages, clearMessagesDOM, appendAssistantPlaceholder, appendSystemMessage,
updateLastAssistantMessage, clearMessages, safeMarkdown, enableAutoScroll, resetAutoScroll, updateLastAssistantMessage, clearMessages, safeMarkdown, enableAutoScroll, resetAutoScroll,
appendToolCallCardToPlaceholder, updateToolCallCardInPlaceholder, resetCurrentPlaceholder, removeCurrentPlaceholder resetCurrentPlaceholder, removeCurrentPlaceholder
} from './chat-area.js'; } from './chat-area.js';
import { showToast } from './toast.js'; import { showToast } from './toast.js';
import { addToolCard, startToolCard, updateToolCard, clearToolCardsExternal, clearTerminalExternal, getWorkspaceDirPath, hasActiveCards, showWorkingHint, clearWorkingHint } from './workspace-panel.js'; import { addToolCard, startToolCard, updateToolCard, clearToolCardsExternal, clearTerminalExternal, getWorkspaceDirPath, hasActiveCards, showWorkingHint, clearWorkingHint } from './workspace-panel.js';
@@ -1098,10 +1098,6 @@ export async function sendMessage(): Promise<void> {
return; return;
} }
/** 更新当前会话消息中最近一个 'running' 状态的同名工具记录 */
function updateMessageToolRecord(toolName: string, status: 'success' | 'error', result: Record<string, unknown>): void {
// 工具卡片统一由 onDone 挂载,不在执行中更新消息记录
}
async function sendMessageWithAgentLoop(text: string, currentSession: ChatSession, model: string): Promise<void> { async function sendMessageWithAgentLoop(text: string, currentSession: ChatSession, model: string): Promise<void> {
const now = Date.now(); const now = Date.now();
@@ -1286,6 +1282,9 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
messages: [...session.messages, prevMsg], messages: [...session.messages, prevMsg],
updatedAt: Date.now() updatedAt: Date.now()
})); }));
// P0-P1 修复:中间迭代消息必须持久化,否则崩溃时前几轮的 assistant 消息全丢失
saveCurrentSession().catch(e => logError('onNewIteration 保存失败', String(e)));
// P1-R3 修复:先移除旧 placeholder → 渲染历史 → 创建新 placeholder,避免渲染空隙
removeCurrentPlaceholder(); removeCurrentPlaceholder();
renderMessages(); renderMessages();
appendAssistantPlaceholder(); 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')) { if ((err as Error).message.includes('Failed to fetch') || (err as Error).message.includes('NetworkError')) {
errMsg = '❌ 连接失败。请检查 Ollama 是否正在运行'; 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); appendSystemMessage(errMsg);
} }
} finally { } finally {
+9 -5
View File
@@ -878,11 +878,15 @@ function updateToolCardDOM(tc: ToolCallRecord): void {
const container = document.querySelector('#wsToolsOutput') as HTMLElement; const container = document.querySelector('#wsToolsOutput') as HTMLElement;
if (!container) return; if (!container) return;
// 查找同名且状态为 pending 或 running 的卡片 DOM 节点 // P1-R4 修复:同名工具并发执行时 querySelector 只返回第一个匹配,可能更新到错误的卡片。
const runningCard = ( // 改为 querySelectorAll 查找所有同名 pending/running 卡片,取最后一个(最新的那个)更新。
container.querySelector(`.tool-call-card[data-tool-name="${escapeAttr(tc.name)}"].tool-call-running`) || // 这与 updateToolCard 函数(行 839-853)在 JS 数组层面反向遍历找最后一个 running/pending 的逻辑一致。
container.querySelector(`.tool-call-card[data-tool-name="${escapeAttr(tc.name)}"].tool-call-pending`) const escapedName = escapeAttr(tc.name);
) as HTMLElement | null; 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) { if (runningCard) {
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
+13 -2
View File
@@ -69,6 +69,9 @@ export class ChatDB {
// ── Sessions ── // ── Sessions ──
// P1-P1 修复:追踪已保存的消息 ID,避免每次 saveSession 全量重写所有消息
private _savedMsgIds = new Set<string>();
async saveSession(session: ChatSession): Promise<string> { async saveSession(session: ChatSession): Promise<string> {
if (isDesktop()) { if (isDesktop()) {
const row = { const row = {
@@ -82,11 +85,14 @@ export class ChatDB {
updated_at: session.updatedAt updated_at: session.updatedAt
}; };
await dbBridge().saveSession(row); await dbBridge().saveSession(row);
// 同步保存消息 // P1-P1 修复:只保存新增消息(不在 _savedMsgIds 中的),避免全量重写
for (let mi = 0; mi < session.messages.length; mi++) { for (let mi = 0; mi < session.messages.length; mi++) {
const msg = session.messages[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 = { const msgRow = {
id: `${session.id}_${msg.timestamp}_${msg.role}_${mi}`, id: msgId,
session_id: session.id, session_id: session.id,
role: msg.role, role: msg.role,
content: msg.content || null, content: msg.content || null,
@@ -107,6 +113,11 @@ export class ChatDB {
return this._idbSaveSession(session); return this._idbSaveSession(session);
} }
/** P1-P1 修复:切换会话时重置已保存消息追踪,释放内存 */
resetSavedMsgTracking(): void {
this._savedMsgIds.clear();
}
async getSession(id: string): Promise<ChatSession | null> { async getSession(id: string): Promise<ChatSession | null> {
if (isDesktop()) { if (isDesktop()) {
const row = await dbBridge().getSession(id); const row = await dbBridge().getSession(id);
+1 -1
View File
@@ -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.16.8</span> <span class="app-version">v0.16.9</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"/>
+86 -19
View File
@@ -194,6 +194,8 @@ const SIDE_EFFECT_TOOLS = new Set([
const _loopToolSignatures: string[] = []; const _loopToolSignatures: string[] = [];
const LOOP_HISTORY_MAX = 10; const LOOP_HISTORY_MAX = 10;
let _deadlockHintInjected = false; let _deadlockHintInjected = false;
// P1-E6 修复:待清理的记忆提取 timer 列表,finally 块统一清理
const _pendingMemoryTimers: ReturnType<typeof setTimeout>[] = [];
/** 记录本轮工具调用签名(每轮 handleThinking 成功后调用一次) */ /** 记录本轮工具调用签名(每轮 handleThinking 成功后调用一次) */
function recordLoopSignature(toolCalls: ToolCall[]): void { function recordLoopSignature(toolCalls: ToolCall[]): void {
@@ -319,7 +321,9 @@ function getAdjustedToolTimeout(toolName: string, args: Record<string, unknown>)
} }
/** R3: /** R3:
* P0 executeTool AbortSignal使 Promise.race * P0-E1 executeTool AbortSignal Promise + settled "伪超时"
* / executeTool fire-and-forget
* write_file/run_command "中止"
*/ */
async function executeToolWithTimeout( async function executeToolWithTimeout(
toolName: string, toolName: string,
@@ -1193,10 +1197,11 @@ function transition(ctx: LoopContext, newState: LoopState): void {
state.set('_loopState', ctx.state); state.set('_loopState', ctx.state);
} }
/** 持久化 LoopContext 到 state(用于暂停/恢复) */ /** LoopContext stateP0-P3 persistLoopContext
function persistLoopContext(ctx: LoopContext): void { * UI */
// P1 #4 修复:中止时持久化 TERMINATED 状态,避免恢复时状态不一致 function snapshotLoopContext(ctx: LoopContext): void {
const persistedState = isAborted() ? 'TERMINATED' : ctx.state; // 中止时强制标记为 TERMINATED,避免 UI 显示错误状态
const snapshotState = isAborted() ? 'TERMINATED' : ctx.state;
state.set('_loopContext', { state.set('_loopContext', {
sessionId: ctx.sessionId, sessionId: ctx.sessionId,
loopCount: ctx.loopCount, loopCount: ctx.loopCount,
@@ -1205,7 +1210,14 @@ function persistLoopContext(ctx: LoopContext): void {
totalPromptEvalCount: ctx.totalPromptEvalCount, totalPromptEvalCount: ctx.totalPromptEvalCount,
allToolRecords: ctx.allToolRecords, allToolRecords: ctx.allToolRecords,
startTime: ctx.startTime, startTime: ctx.startTime,
state: persistedState, state: snapshotState,
// P0-P3 修复:补全关键字段,至少保证运行时调试可见
mode: ctx.mode,
emergencyCompressCount: ctx.emergencyCompressCount,
completionGateFailCount: ctx.completionGateFailCount,
verifyWarnings: ctx.verifyWarnings,
compressedThisCycle: ctx.compressedThisCycle,
messageCount: ctx.messages.length,
}); });
} }
@@ -1312,6 +1324,8 @@ setUserGoal(userContent); // R56: 设置用户原始目标
// P2-11: 加载自定义文件 // P2-11: 加载自定义文件
await loadCustomFiles(workspaceDir || '', systemPromptParts); await loadCustomFiles(workspaceDir || '', systemPromptParts);
// P1-E1 修复:handleInit 中的异步操作后检查中止信号,避免大型工作空间中止延迟
if (isAborted()) { throw new DOMException('Aborted', 'AbortError'); }
// 注入记忆上下文 // 注入记忆上下文
if (userContent) { if (userContent) {
@@ -1321,6 +1335,8 @@ setUserGoal(userContent); // R56: 设置用户原始目标
systemPromptParts.push(formatMemoryContext(relevantMemories)); systemPromptParts.push(formatMemoryContext(relevantMemories));
} }
} catch { /* 记忆搜索失败不影响主流程 */ } } catch { /* 记忆搜索失败不影响主流程 */ }
// P1-E1 修复:记忆搜索后检查中止
if (isAborted()) { throw new DOMException('Aborted', 'AbortError'); }
} }
// 注入工作空间上下文 // 注入工作空间上下文
@@ -1420,7 +1436,10 @@ setUserGoal(userContent); // R56: 设置用户原始目标
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 });
if (compressed.length < ctx.messages.length || estimateTokens(compressed.map(m => m.content || '').join('')) < estimateTokens(ctx.messages.map(m => m.content || '').join(''))) { // P1-C4 修复:handleInit 中的自动压缩也使用 AND 条件
const initBeforeTokens = estimateTokens(ctx.messages.map(m => m.content || '').join(''));
const initAfterTokens = estimateTokens(compressed.map(m => m.content || '').join(''));
if (compressed.length < ctx.messages.length && initAfterTokens < initBeforeTokens) {
ctx.messages.length = 0; ctx.messages.length = 0;
ctx.messages.push(...compressed); ctx.messages.push(...compressed);
logSuccess(`自动上下文压缩完成: 剩余 ${ctx.messages.length} 条消息`); logSuccess(`自动上下文压缩完成: 剩余 ${ctx.messages.length} 条消息`);
@@ -1876,7 +1895,10 @@ async function handleThinking(
try { try {
const compressAC = new AbortController(); const compressAC = new AbortController();
const compressed = await compressWithLLM(ctx.messages, api as any, model, { abortController: compressAC }); const compressed = await compressWithLLM(ctx.messages, api as any, model, { abortController: compressAC });
if (compressed.length < ctx.messages.length) { // P1-C4 修复:R8 紧急压缩也使用 AND 条件,避免接受 token 增加的结果
const r8BeforeTokens = estimateTokens(ctx.messages.map(m => m.content || '').join(''));
const r8AfterTokens = estimateTokens(compressed.map(m => m.content || '').join(''));
if (compressed.length < ctx.messages.length && r8AfterTokens < r8BeforeTokens) {
ctx.messages.length = 0; ctx.messages.length = 0;
ctx.messages.push(...compressed); ctx.messages.push(...compressed);
logSuccess(`R8: 紧急压缩完成,剩余 ${ctx.messages.length} 条消息`); logSuccess(`R8: 紧急压缩完成,剩余 ${ctx.messages.length} 条消息`);
@@ -2163,6 +2185,15 @@ async function handleExecuting(
if (needsConfirmation(call.function.name)) { if (needsConfirmation(call.function.name)) {
const confirmed = await callbacks.onConfirmTool(call); const confirmed = await callbacks.onConfirmTool(call);
// P1-E3 修复:用户在确认对话框期间可能点了"停止",确认后检查中止信号
if (isAborted()) {
logInfo(`用户在工具确认期间中止: ${call.function.name}`);
return [{
name: call.function.name, arguments: call.function.arguments,
result: { success: false, error: '用户中止' },
status: 'cancelled' as const, timestamp: Date.now()
}, null];
}
if (!confirmed) { if (!confirmed) {
logWarn(`工具取消: ${call.function.name}`); logWarn(`工具取消: ${call.function.name}`);
const cancelResult = { success: false, error: '用户取消了操作' }; const cancelResult = { success: false, error: '用户取消了操作' };
@@ -2259,9 +2290,12 @@ async function handleExecuting(
// 按批次执行 // 按批次执行
for (const batch of batches) { for (const batch of batches) {
if (state.get<AbortController | null>(KEYS.ABORT_CONTROLLER)?.signal.aborted) { if (state.get<AbortController | null>(KEYS.ABORT_CONTROLLER)?.signal.aborted) {
callbacks.onDone(ctx.content, ctx.allToolRecords.length > 0 ? ctx.allToolRecords : undefined, makeStats(ctx)); // P0-R1 + P1-R5 修复:中止时设置 _abortToolRecords 并 throw AbortError
transition(ctx, S.TERMINATED); // 让主循环 catch 块统一处理 UI 反馈([已停止] 标记、系统消息等),避免 onDone 被重复调用
return; if (ctx.allToolRecords.length > 0) {
state.set('_abortToolRecords', ctx.allToolRecords);
}
throw new DOMException('Aborted', 'AbortError');
} }
// R6: 限制并行工具数量,避免过多并发导致资源争抢 // R6: 限制并行工具数量,避免过多并发导致资源争抢
@@ -2358,7 +2392,7 @@ async function handleObserving(
const toRemove = new Set<number>(toRemoveToolIndices); const toRemove = new Set<number>(toRemoveToolIndices);
const toStripToolCalls = new Set<number>(); const toStripToolCalls = new Set<number>();
// 检查每个带 tool_calls 的 assistant:如果其所有 tool 消息被删除,处理 assistant // 检查每个带 tool_calls 的 assistant:如果其 tool 消息被部分或全部删除,处理 assistant
for (let i = 0; i < ctx.messages.length; i++) { for (let i = 0; i < ctx.messages.length; i++) {
const msg = ctx.messages[i]; const msg = ctx.messages[i];
if (msg.role === 'assistant' && msg.tool_calls?.length) { if (msg.role === 'assistant' && msg.tool_calls?.length) {
@@ -2367,7 +2401,9 @@ async function handleObserving(
followingToolIndices.push(j); followingToolIndices.push(j);
} }
if (followingToolIndices.length === 0) continue; if (followingToolIndices.length === 0) continue;
const allRemoved = followingToolIndices.every(idx => toRemoveToolIndices.has(idx)); const removedCount = followingToolIndices.filter(idx => toRemoveToolIndices.has(idx)).length;
const allRemoved = removedCount === followingToolIndices.length;
const someRemoved = removedCount > 0 && !allRemoved;
if (allRemoved) { if (allRemoved) {
if (msg.content && msg.content.trim()) { if (msg.content && msg.content.trim()) {
// 有内容:保留 assistant 但移除 tool_calls // 有内容:保留 assistant 但移除 tool_calls
@@ -2376,6 +2412,10 @@ async function handleObserving(
// 无内容:删除整个 assistant // 无内容:删除整个 assistant
toRemove.add(i); toRemove.add(i);
} }
} else if (someRemoved) {
// P0-C2 修复:部分 tool 消息被删除时,assistant.tool_calls 仍引用被删除的 tool_call_id
// 会导致 Ollama API 报错。必须移除 tool_calls(降级为纯文本 assistant
toStripToolCalls.add(i);
} }
} }
} }
@@ -2483,6 +2523,8 @@ async function handleObserving(
const PRESERVE_PATTERNS = [ const PRESERVE_PATTERNS = [
/Plan Mode/, /计划已批准/, /计划已拒绝/, /最大计划重试/, /Plan Mode/, /计划已批准/, /计划已拒绝/, /最大计划重试/,
/plan_track/, /当前进度/, /断点续传/, /plan_track/, /当前进度/, /断点续传/,
// P1-E5 修复:保留死锁检测器注入的 ephemeral 提示,否则提示被清理后死锁会硬性熔断
/系统提示.*连续.*轮调用相同的工具/,
]; ];
let removed = 0; let removed = 0;
ctx.messages = ctx.messages.filter(m => { ctx.messages = ctx.messages.filter(m => {
@@ -2705,11 +2747,16 @@ async function handleReflecting(
.filter(m => m.role === 'user' || m.role === 'assistant') .filter(m => m.role === 'user' || m.role === 'assistant')
.map(m => ({ role: m.role, content: m.content || '' })); .map(m => ({ role: m.role, content: m.content || '' }));
// 使用 setTimeout 延迟执行,确保 onDone 先触发,用户先看到回复 // 使用 setTimeout 延迟执行,确保 onDone 先触发,用户先看到回复
setTimeout(() => { // P1-E6 修复:存储 timer ID 到 ctx,finally 块可清理,避免新会话启动时旧提取污染
const memTimer = setTimeout(() => {
// 执行前再次检查 signal,可能已被新会话的 registerAbortController 中止
if (memorySignal?.aborted) return;
import('./memory-service.js').then(({ extractAndSaveMemories }) => { import('./memory-service.js').then(({ extractAndSaveMemories }) => {
extractAndSaveMemories(msgsForMemory, _api, _model, memorySignal).catch(() => {}); extractAndSaveMemories(msgsForMemory, _api, _model, memorySignal).catch(() => {});
}).catch(() => {}); }).catch(() => {});
}, 500); }, 500);
// 存储 timerfinally 块在 cleanupAbortController 之后清理
_pendingMemoryTimers.push(memTimer);
} }
callbacks.onDone(ctx.content || '(模型未返回内容)', ctx.allToolRecords.length > 0 ? ctx.allToolRecords : undefined, makeStats(ctx)); callbacks.onDone(ctx.content || '(模型未返回内容)', ctx.allToolRecords.length > 0 ? ctx.allToolRecords : undefined, makeStats(ctx));
@@ -2726,12 +2773,24 @@ async function handleCompressing(
): Promise<void> { ): Promise<void> {
const numCtx = getEffectiveNumCtx(); const numCtx = getEffectiveNumCtx();
let actuallyCompressed = false; let actuallyCompressed = false;
if (shouldAutoCompress(ctx.messages, numCtx)) { // P1-C1 修复:OBSERVING 基于 getTrendAwareCompressThreshold 触发 COMPRESSING
// 此处不应再用 shouldAutoCompress 二次判断(两者阈值不一致会导致跳过压缩形成低效循环)。
// 既然已进入 COMPRESSING 状态,直接执行压缩。
// P1-C2 修复:进入时检查中止信号
if (isAborted()) {
transition(ctx, S.REFLECTING);
return;
}
{
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();
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 });
if (compressed.length < ctx.messages.length || estimateTokens(compressed.map(m => m.content || '').join('')) < estimateTokens(ctx.messages.map(m => m.content || '').join(''))) { // P1-C4 修复:原条件用 OR(消息数减少 OR token 减少),可能接受 token 增加的结果。
// 改为 AND:只有消息数减少且 token 减少时才接受,确保压缩真正生效
const beforeTokens = estimateTokens(ctx.messages.map(m => m.content || '').join(''));
const afterTokens = estimateTokens(compressed.map(m => m.content || '').join(''));
if (compressed.length < ctx.messages.length && afterTokens < beforeTokens) {
ctx.messages.length = 0; ctx.messages.length = 0;
ctx.messages.push(...compressed); ctx.messages.push(...compressed);
actuallyCompressed = true; actuallyCompressed = true;
@@ -2828,7 +2887,7 @@ export async function runAgentLoop(
}; };
state.set('_loopState', S.INIT); state.set('_loopState', S.INIT);
persistLoopContext(ctx); snapshotLoopContext(ctx);
startSessionMetrics(sessionId, model); startSessionMetrics(sessionId, model);
// Plan Mode 激活时注册 plan_track 工具 // Plan Mode 激活时注册 plan_track 工具
const { setPlanModeActive } = await import('./tool-registry.js'); const { setPlanModeActive } = await import('./tool-registry.js');
@@ -2929,7 +2988,7 @@ default:
} }
} }
persistLoopContext(ctx); snapshotLoopContext(ctx);
} }
} catch (err) { } catch (err) {
if ((err as Error).name === 'AbortError') { if ((err as Error).name === 'AbortError') {
@@ -2940,6 +2999,8 @@ default:
callbacks.onDone(ctx.content || 'Agent Loop 异常终止)', ctx.allToolRecords.length > 0 ? ctx.allToolRecords : undefined, makeStats(ctx)); callbacks.onDone(ctx.content || 'Agent Loop 异常终止)', ctx.allToolRecords.length > 0 ? ctx.allToolRecords : undefined, makeStats(ctx));
} }
transition(ctx, S.TERMINATED); transition(ctx, S.TERMINATED);
// P1-E2 修复:catch 块也需快照,确保终止状态写入 state._loopContext
snapshotLoopContext(ctx);
} finally { } finally {
// ── P1-8: Plan Mode 断点续传 — 保存完整追踪器到 session,支持跨轮次恢复 ── // ── P1-8: Plan Mode 断点续传 — 保存完整追踪器到 session,支持跨轮次恢复 ──
if (ctx.mode === 'plan') { if (ctx.mode === 'plan') {
@@ -2960,6 +3021,12 @@ default:
clearPlanTracker(); clearPlanTracker();
endSessionMetrics(); endSessionMetrics();
cleanupAbortController(); cleanupAbortController();
flushAllTraces(); // P3-13: 确保所有轨迹写入 SQLite // P1-E6 修复:清理未执行的记忆提取 timer,避免新会话启动时旧提取污染
while (_pendingMemoryTimers.length > 0) {
const t = _pendingMemoryTimers.pop();
if (t) clearTimeout(t);
}
// P1-E4 修复:await 确保轨迹写入完成,避免应用立即关闭时丢失
await flushAllTraces().catch(() => {});
} }
} }
+31 -3
View File
@@ -632,6 +632,30 @@ export async function compressWithLLM(
const tail = nonSystemMsgs.slice(-keepTail); const tail = nonSystemMsgs.slice(-keepTail);
const middle = nonSystemMsgs.slice(keepHead, nonSystemMsgs.length - keepTail); const middle = nonSystemMsgs.slice(keepHead, nonSystemMsgs.length - keepTail);
// P1-C6 修复:head 中最早的 assistant+tool_calls 组在多次压缩后无法清除,导致 token 膨胀。
// 对 head 中已压缩过的消息(compressed=true),移除其 tool_calls 和后续 tool 消息(保留 content 作为上下文)。
// 这些旧工具调用的结果已不再需要,但 assistant 的文本内容仍有上下文价值。
if (head.length > 0) {
for (let i = 0; i < head.length; i++) {
const m = head[i];
if (m.compressed && m.tool_calls?.length) {
// 移除 tool_calls(降级为纯文本 assistant
const newMsg: OllamaMessage = { ...m };
delete newMsg.tool_calls;
head[i] = newMsg;
}
if (m.compressed && m.role === 'tool') {
// 旧的 tool 消息标记为空(保留位置但内容为空,避免破坏数组结构)
// 后续 mergeConsecutiveMessages 会合并这些空消息
head[i] = { ...m, content: '', compressed: true };
}
}
// 过滤掉 head 中被清空的 tool 消息
const filteredHead = head.filter(m => !(m.compressed && m.role === 'tool' && !m.content));
head.length = 0;
head.push(...filteredHead);
}
// R15: 对话轮次边界保护 — 调整 head/tail 切分点,避免在对话轮次中间切割 // R15: 对话轮次边界保护 — 调整 head/tail 切分点,避免在对话轮次中间切割
// 如果 head 末尾是带 tool_calls 的 assistant,将后续 tool 消息也纳入 head // 如果 head 末尾是带 tool_calls 的 assistant,将后续 tool 消息也纳入 head
if (head.length > 0 && head[head.length - 1].tool_calls?.length) { if (head.length > 0 && head[head.length - 1].tool_calls?.length) {
@@ -651,6 +675,10 @@ export async function compressWithLLM(
if (extendBack >= 0) { if (extendBack >= 0) {
const moved = middle.splice(extendBack); const moved = middle.splice(extendBack);
tail.unshift(...moved); tail.unshift(...moved);
} else {
// P1-C3 修复:middle 中无 assistant 时,tail[0] 是孤立 tool 消息,
// Ollama 会拒绝或忽略。将其从 tail 移除(它无对应 assistant.tool_calls
tail.shift();
} }
} }
@@ -765,7 +793,8 @@ export async function compressWithLLM(
// R16: 保留已压缩的中间消息(system 消息单独处理)+ 新摘要 // R16: 保留已压缩的中间消息(system 消息单独处理)+ 新摘要
// R12: 标记这批消息为已压缩 // R12: 标记这批消息为已压缩
markAsCompressed(uncompressedMiddle); markAsCompressed(uncompressedMiddle);
// R16: 已压缩的旧摘要已被合并到新摘要中,不再保留它们 // P0-C1 修复:原代码保留 nonSystemCompressed(旧摘要),但新摘要已通过 mergeSummaries 合并了旧摘要内容,
// 保留旧摘要会导致信息重复 + token 累积浪费。正确做法是不保留旧摘要(system 的已在 mergedSystemContent 中合并)
const alreadyCompressed = middle.filter(m => m.compressed && m.role === 'system'); const alreadyCompressed = middle.filter(m => m.compressed && m.role === 'system');
// C1: 合并 system 消息为一条,但保留不可压缩的 system 消息完整内容 // C1: 合并 system 消息为一条,但保留不可压缩的 system 消息完整内容
@@ -775,12 +804,11 @@ export async function compressWithLLM(
...alreadyCompressed.filter(m => m.role === 'system').map(m => m.content || ''), ...alreadyCompressed.filter(m => m.role === 'system').map(m => m.content || ''),
].filter(Boolean).join('\n\n'); ].filter(Boolean).join('\n\n');
const nonSystemCompressed = alreadyCompressed.filter(m => m.role !== 'system'); // P0-C1 修复:不再保留 nonSystemCompressed,旧摘要内容已合并到 summaryMsg 中
const result: OllamaMessage[] = [ const result: OllamaMessage[] = [
{ role: 'system', content: mergedSystemContent }, { role: 'system', content: mergedSystemContent },
...head, ...head,
...nonSystemCompressed,
summaryMsg, summaryMsg,
...tail ...tail
]; ];