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:
@@ -14,7 +14,7 @@
|
||||
</p>
|
||||
|
||||
<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/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">
|
||||
@@ -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
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+15
-13
@@ -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<T = unknown>(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<T = unknown>(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 ───
|
||||
|
||||
|
||||
+7
-11
@@ -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<void> {
|
||||
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) => {
|
||||
|
||||
+1
-1
@@ -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()
|
||||
});
|
||||
|
||||
@@ -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 从外部被移除时调用) */
|
||||
|
||||
@@ -177,6 +177,8 @@ async function loadHistorySession(sessionId: string): Promise<void> {
|
||||
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';
|
||||
|
||||
@@ -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<void> {
|
||||
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> {
|
||||
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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -69,6 +69,9 @@ export class ChatDB {
|
||||
|
||||
// ── Sessions ──
|
||||
|
||||
// P1-P1 修复:追踪已保存的消息 ID,避免每次 saveSession 全量重写所有消息
|
||||
private _savedMsgIds = new Set<string>();
|
||||
|
||||
async saveSession(session: ChatSession): Promise<string> {
|
||||
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<ChatSession | null> {
|
||||
if (isDesktop()) {
|
||||
const row = await dbBridge().getSession(id);
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<div class="header-left">
|
||||
<img class="logo" src="./assets/icons/llama.png" alt="logo" />
|
||||
<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="使用帮助">
|
||||
<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"/>
|
||||
|
||||
@@ -194,6 +194,8 @@ const SIDE_EFFECT_TOOLS = new Set([
|
||||
const _loopToolSignatures: string[] = [];
|
||||
const LOOP_HISTORY_MAX = 10;
|
||||
let _deadlockHintInjected = false;
|
||||
// P1-E6 修复:待清理的记忆提取 timer 列表,finally 块统一清理
|
||||
const _pendingMemoryTimers: ReturnType<typeof setTimeout>[] = [];
|
||||
|
||||
/** 记录本轮工具调用签名(每轮 handleThinking 成功后调用一次) */
|
||||
function recordLoopSignature(toolCalls: ToolCall[]): void {
|
||||
@@ -319,7 +321,9 @@ function getAdjustedToolTimeout(toolName: string, args: Record<string, unknown>)
|
||||
}
|
||||
|
||||
/** R3: 带超时的工具执行包装器
|
||||
* P0 修复:executeTool 不接受 AbortSignal,使用 Promise.race 实现真正的超时中断
|
||||
* P0-E1 修正:executeTool 不接受 AbortSignal,此处用 Promise + settled 标志实现"伪超时"。
|
||||
* 注意:超时/中止后底层 executeTool 仍在后台执行(fire-and-forget),对有副作用的工具
|
||||
* (write_file/run_command 等)用户应知晓"中止"只是不再等待结果,副作用可能已发生。
|
||||
*/
|
||||
async function executeToolWithTimeout(
|
||||
toolName: string,
|
||||
@@ -1193,10 +1197,11 @@ function transition(ctx: LoopContext, newState: LoopState): void {
|
||||
state.set('_loopState', ctx.state);
|
||||
}
|
||||
|
||||
/** 持久化 LoopContext 到 state(用于暂停/恢复) */
|
||||
function persistLoopContext(ctx: LoopContext): void {
|
||||
// P1 #4 修复:中止时持久化 TERMINATED 状态,避免恢复时状态不一致
|
||||
const persistedState = isAborted() ? 'TERMINATED' : ctx.state;
|
||||
/** 快照 LoopContext 到内存 state(P0-P3 修复:原 persistLoopContext 名不副实,实际只写内存不落盘)
|
||||
* 用途:运行时状态快照,供 UI 显示和调试;不用于跨会话恢复(应用重启后全丢) */
|
||||
function snapshotLoopContext(ctx: LoopContext): void {
|
||||
// 中止时强制标记为 TERMINATED,避免 UI 显示错误状态
|
||||
const snapshotState = isAborted() ? 'TERMINATED' : ctx.state;
|
||||
state.set('_loopContext', {
|
||||
sessionId: ctx.sessionId,
|
||||
loopCount: ctx.loopCount,
|
||||
@@ -1205,7 +1210,14 @@ function persistLoopContext(ctx: LoopContext): void {
|
||||
totalPromptEvalCount: ctx.totalPromptEvalCount,
|
||||
allToolRecords: ctx.allToolRecords,
|
||||
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: 加载自定义文件
|
||||
await loadCustomFiles(workspaceDir || '', systemPromptParts);
|
||||
// P1-E1 修复:handleInit 中的异步操作后检查中止信号,避免大型工作空间中止延迟
|
||||
if (isAborted()) { throw new DOMException('Aborted', 'AbortError'); }
|
||||
|
||||
// 注入记忆上下文
|
||||
if (userContent) {
|
||||
@@ -1321,6 +1335,8 @@ setUserGoal(userContent); // R56: 设置用户原始目标
|
||||
systemPromptParts.push(formatMemoryContext(relevantMemories));
|
||||
}
|
||||
} 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();
|
||||
try {
|
||||
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.push(...compressed);
|
||||
logSuccess(`自动上下文压缩完成: 剩余 ${ctx.messages.length} 条消息`);
|
||||
@@ -1876,7 +1895,10 @@ async function handleThinking(
|
||||
try {
|
||||
const compressAC = new AbortController();
|
||||
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.push(...compressed);
|
||||
logSuccess(`R8: 紧急压缩完成,剩余 ${ctx.messages.length} 条消息`);
|
||||
@@ -2163,6 +2185,15 @@ async function handleExecuting(
|
||||
|
||||
if (needsConfirmation(call.function.name)) {
|
||||
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) {
|
||||
logWarn(`工具取消: ${call.function.name}`);
|
||||
const cancelResult = { success: false, error: '用户取消了操作' };
|
||||
@@ -2259,9 +2290,12 @@ async function handleExecuting(
|
||||
// 按批次执行
|
||||
for (const batch of batches) {
|
||||
if (state.get<AbortController | null>(KEYS.ABORT_CONTROLLER)?.signal.aborted) {
|
||||
callbacks.onDone(ctx.content, ctx.allToolRecords.length > 0 ? ctx.allToolRecords : undefined, makeStats(ctx));
|
||||
transition(ctx, S.TERMINATED);
|
||||
return;
|
||||
// P0-R1 + P1-R5 修复:中止时设置 _abortToolRecords 并 throw AbortError,
|
||||
// 让主循环 catch 块统一处理 UI 反馈([已停止] 标记、系统消息等),避免 onDone 被重复调用
|
||||
if (ctx.allToolRecords.length > 0) {
|
||||
state.set('_abortToolRecords', ctx.allToolRecords);
|
||||
}
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
|
||||
// R6: 限制并行工具数量,避免过多并发导致资源争抢
|
||||
@@ -2358,7 +2392,7 @@ async function handleObserving(
|
||||
const toRemove = new Set<number>(toRemoveToolIndices);
|
||||
const toStripToolCalls = new Set<number>();
|
||||
|
||||
// 检查每个带 tool_calls 的 assistant:如果其所有 tool 消息都被删除,处理 assistant
|
||||
// 检查每个带 tool_calls 的 assistant:如果其 tool 消息被部分或全部删除,处理 assistant
|
||||
for (let i = 0; i < ctx.messages.length; i++) {
|
||||
const msg = ctx.messages[i];
|
||||
if (msg.role === 'assistant' && msg.tool_calls?.length) {
|
||||
@@ -2367,7 +2401,9 @@ async function handleObserving(
|
||||
followingToolIndices.push(j);
|
||||
}
|
||||
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 (msg.content && msg.content.trim()) {
|
||||
// 有内容:保留 assistant 但移除 tool_calls
|
||||
@@ -2376,6 +2412,10 @@ async function handleObserving(
|
||||
// 无内容:删除整个 assistant
|
||||
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 = [
|
||||
/Plan Mode/, /计划已批准/, /计划已拒绝/, /最大计划重试/,
|
||||
/plan_track/, /当前进度/, /断点续传/,
|
||||
// P1-E5 修复:保留死锁检测器注入的 ephemeral 提示,否则提示被清理后死锁会硬性熔断
|
||||
/系统提示.*连续.*轮调用相同的工具/,
|
||||
];
|
||||
let removed = 0;
|
||||
ctx.messages = ctx.messages.filter(m => {
|
||||
@@ -2705,11 +2747,16 @@ async function handleReflecting(
|
||||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||
.map(m => ({ role: m.role, content: m.content || '' }));
|
||||
// 使用 setTimeout 延迟执行,确保 onDone 先触发,用户先看到回复
|
||||
setTimeout(() => {
|
||||
// P1-E6 修复:存储 timer ID 到 ctx,finally 块可清理,避免新会话启动时旧提取污染
|
||||
const memTimer = setTimeout(() => {
|
||||
// 执行前再次检查 signal,可能已被新会话的 registerAbortController 中止
|
||||
if (memorySignal?.aborted) return;
|
||||
import('./memory-service.js').then(({ extractAndSaveMemories }) => {
|
||||
extractAndSaveMemories(msgsForMemory, _api, _model, memorySignal).catch(() => {});
|
||||
}).catch(() => {});
|
||||
}, 500);
|
||||
// 存储 timer,finally 块在 cleanupAbortController 之后清理
|
||||
_pendingMemoryTimers.push(memTimer);
|
||||
}
|
||||
|
||||
callbacks.onDone(ctx.content || '(模型未返回内容)', ctx.allToolRecords.length > 0 ? ctx.allToolRecords : undefined, makeStats(ctx));
|
||||
@@ -2726,12 +2773,24 @@ async function handleCompressing(
|
||||
): Promise<void> {
|
||||
const numCtx = getEffectiveNumCtx();
|
||||
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: 上下文压缩触发');
|
||||
const compressAC = state.get<AbortController | null>(KEYS.ABORT_CONTROLLER) || new AbortController();
|
||||
try {
|
||||
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.push(...compressed);
|
||||
actuallyCompressed = true;
|
||||
@@ -2828,7 +2887,7 @@ export async function runAgentLoop(
|
||||
};
|
||||
|
||||
state.set('_loopState', S.INIT);
|
||||
persistLoopContext(ctx);
|
||||
snapshotLoopContext(ctx);
|
||||
startSessionMetrics(sessionId, model);
|
||||
// Plan Mode 激活时注册 plan_track 工具
|
||||
const { setPlanModeActive } = await import('./tool-registry.js');
|
||||
@@ -2929,7 +2988,7 @@ default:
|
||||
}
|
||||
}
|
||||
|
||||
persistLoopContext(ctx);
|
||||
snapshotLoopContext(ctx);
|
||||
}
|
||||
} catch (err) {
|
||||
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));
|
||||
}
|
||||
transition(ctx, S.TERMINATED);
|
||||
// P1-E2 修复:catch 块也需快照,确保终止状态写入 state._loopContext
|
||||
snapshotLoopContext(ctx);
|
||||
} finally {
|
||||
// ── P1-8: Plan Mode 断点续传 — 保存完整追踪器到 session,支持跨轮次恢复 ──
|
||||
if (ctx.mode === 'plan') {
|
||||
@@ -2960,6 +3021,12 @@ default:
|
||||
clearPlanTracker();
|
||||
endSessionMetrics();
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -632,6 +632,30 @@ export async function compressWithLLM(
|
||||
const tail = nonSystemMsgs.slice(-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 切分点,避免在对话轮次中间切割
|
||||
// 如果 head 末尾是带 tool_calls 的 assistant,将后续 tool 消息也纳入 head
|
||||
if (head.length > 0 && head[head.length - 1].tool_calls?.length) {
|
||||
@@ -651,6 +675,10 @@ export async function compressWithLLM(
|
||||
if (extendBack >= 0) {
|
||||
const moved = middle.splice(extendBack);
|
||||
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 消息单独处理)+ 新摘要
|
||||
// R12: 标记这批消息为已压缩
|
||||
markAsCompressed(uncompressedMiddle);
|
||||
// R16: 已压缩的旧摘要已被合并到新摘要中,不再保留它们
|
||||
// P0-C1 修复:原代码保留 nonSystemCompressed(旧摘要),但新摘要已通过 mergeSummaries 合并了旧摘要内容,
|
||||
// 保留旧摘要会导致信息重复 + token 累积浪费。正确做法是不保留旧摘要(system 的已在 mergedSystemContent 中合并)
|
||||
const alreadyCompressed = middle.filter(m => m.compressed && m.role === 'system');
|
||||
|
||||
// C1: 合并 system 消息为一条,但保留不可压缩的 system 消息完整内容
|
||||
@@ -775,12 +804,11 @@ export async function compressWithLLM(
|
||||
...alreadyCompressed.filter(m => m.role === 'system').map(m => m.content || ''),
|
||||
].filter(Boolean).join('\n\n');
|
||||
|
||||
const nonSystemCompressed = alreadyCompressed.filter(m => m.role !== 'system');
|
||||
// P0-C1 修复:不再保留 nonSystemCompressed,旧摘要内容已合并到 summaryMsg 中
|
||||
|
||||
const result: OllamaMessage[] = [
|
||||
{ role: 'system', content: mergedSystemContent },
|
||||
...head,
|
||||
...nonSystemCompressed,
|
||||
summaryMsg,
|
||||
...tail
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user