diff --git a/README.md b/README.md
index 694b745..c9a3e79 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -44,7 +44,7 @@
| 🔍 | **联网搜索(双模式)** | SearXNG 元搜索引擎 JSON API(70+引擎聚合)/ 四引擎 HTML 解析(Bing+百度+搜狗+360),双模式可切换;web_fetch 支持反爬+UA切换+浏览器回退 |
| 🌏 | **浏览器控制** | 打开网页 · 截图 · 执行 JS · 提取内容 · 点击 · 输入 · 滚动 · 关闭 |
| 🖥️ | **工作空间面板** | 终端(增量流式输出)+ 文件浏览器,命令安全检查 |
-| 🔢 | **上下文长度手动控制** | 设置面板下拉选择(128K / 256K / 512K / 1M),默认 128K,模型栏显示当前配置值,下拉框中显示每个模型自身的上下文长度 |
+| 🔢 | **上下文长度手动控制** | 设置面板下拉选择(8K–1M 共 8 档),默认 128K,模型栏显示当前配置值,下拉框中显示每个模型自身的上下文长度 |
| 🗜️ | **智能上下文管理** | 滑动窗口 + Token 自动校准 + 消息重要性评分 + LLM 结构化 JSON 压缩,智能触发(120 条增量压缩 + 300 条硬上限) |
| ⏱️ | **智能超时保护** | Agent Loop 看门狗(可配,默认 30min)+ 流式总超时(可配,默认 300s)+ 工具 HTTP/MCP 超时可配 |
| 🪝 | **Hook 系统** | 4 阶段生命周期钩子(pre_tool / post_tool / post_iteration / pre_completion),内置安全检查(SecurityCheck)与文件写入去重(FileWriteDedup) |
@@ -132,7 +132,7 @@
| 工具 | 功能 |
|------|------|
-| `memory` | 统一记忆管理(5 个 action:search 搜索 / add 添加 / replace 替换 / remove 删除 / read_all 读取全部)。工作空间 MEMORY.md 受路径保护,仅此工具可访问 |
+| `memory` | 统一记忆管理(6 个 action:search 搜索 / add 添加 / replace 替换 / remove 删除 / remove_batch 批量删除 / read_all 读取全部)。工作空间 MEMORY.md 受路径保护,仅此工具可访问 |
@@ -176,7 +176,7 @@
↓
提示词加固(参考数据标记 + 工具结果安全规则)
↓
- Ollama API (流式响应,num_ctx 用户可配 128K/256K/512K/1M)
+ Ollama API (流式响应,num_ctx 用户可配 8K–1M 共 8 档)
↓
Tool Registry (33 内置 + MCP 动态 + Plan Mode plan_track)
↓
@@ -251,7 +251,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
```
-产出:`release/Metona Ollama Setup v0.17.1.exe`
+产出:`release/Metona Ollama Setup v0.17.2.exe`
## 🛠️ 常用命令
@@ -293,7 +293,7 @@ npm run dist # 构建 Windows 安装包
| 🔍 | **Web Search (dual-mode)** | SearXNG meta-search JSON API (70+ engines) / quad-engine HTML parsing (Bing+Baidu+Sogou+360), switchable; web_fetch with auto-retry+mobile UA+SPA browser fallback |
| 🌏 | **Browser Control** | Open pages · Screenshot · JS execution · Content extraction · Click · Type · Scroll · Close |
| 🖥️ | **Workspace Panel** | Terminal (incremental streaming) + file browser, command security checks |
-| 🔢 | **Manual Context Length** | Settings dropdown (128K / 256K / 512K / 1M), default 128K. Model bar shows configured value, dropdown shows per-model context length |
+| 🔢 | **Manual Context Length** | Settings dropdown (8 tiers, 8K–1M), default 128K. Model bar shows configured value, dropdown shows per-model context length |
| 🗜️ | **Smart Context Manager** | Sliding window + Token auto-calibration + message importance scoring + LLM structured JSON compression, smart triggers (120 msg incremental + 300 msg hard limit) |
| ⏱️ | **Smart Timeout Protection** | Agent Loop watchdog (configurable, default 30min) + stream total timeout (configurable, default 300s) + configurable HTTP/MCP timeouts |
| 🪝 | **Hook System** | 4-phase lifecycle hooks (pre_tool / post_tool / post_iteration / pre_completion), built-in SecurityCheck + FileWriteDedup |
@@ -380,7 +380,7 @@ npm run dist # 构建 Windows 安装包
| Tool | Function |
|------|------|
-| `memory` | Unified memory management (5 actions: search / add / replace / remove / read_all). Workspace MEMORY.md is path-protected, only this tool can access it |
+| `memory` | Unified memory management (6 actions: search / add / replace / remove / remove_batch / read_all). Workspace MEMORY.md is path-protected, only this tool can access it |
@@ -424,7 +424,7 @@ User message → workspace SOUL.md (never compressed) → AGENT.md → USER.md (
↓
Prompt Hardening (reference data markers + tool result safety rules)
↓
- Ollama API (Streaming Response, num_ctx user-configurable 128K/256K/512K/1M)
+ Ollama API (Streaming Response, num_ctx user-configurable 8 tiers, 8K–1M)
↓
Tool Registry (33 Built-in + MCP Dynamic + Plan Mode plan_track)
↓
@@ -499,7 +499,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
```
-Output: `release/Metona Ollama Setup v0.17.1.exe`
+Output: `release/Metona Ollama Setup v0.17.2.exe`
## 🛠️ Common Commands
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index 13c0658..a308ab6 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -86,7 +86,7 @@ src/
│ │ ├── tool-confirm-modal.ts # 工具执行确认对话框(主/子代理共用)
│ │ ├── tools-modal.ts # 工具列表面板(33 个工具卡片)
│ │ └── workspace-panel.ts # 工作空间面板(终端 + 工具卡片 + 文件浏览)
-│ ├── services/ # 12 个服务模块
+│ ├── services/ # 14 个服务模块(含 history-builder 历史消息构建)
│ │ ├── agent-engine.ts # ★ ReAct Agent Loop 核心引擎(8 状态机)
│ │ ├── tool-registry.ts # 工具注册与调度中心(33 内置 + MCP 动态 + Plan Mode)
│ │ ├── result-formatter.ts # 工具结果 → 模型友好格式(纯函数,自 agent-engine 拆分)
@@ -122,7 +122,8 @@ src/
│
└── docs/ # 项目文档
├── DEVELOPMENT.md # 本文档
- └── AI_Agent_ReAct_Harness_Engineering.md # Harness Engineering 学术参考
+ ├── Agentic-Loop详解.md # Agentic Loop 概念与分层体系参考
+ └── ollama-api-docs-20260518.html # Ollama API 参考(离线快照)
```
---
@@ -237,6 +238,7 @@ INIT → THINKING → PARSING → EXECUTING → OBSERVING → REFLECTING → (CO
- 应用退出(before-quit)强制刷盘,崩溃时最多丢失最近 300ms 写入
- Schema 使用 `PRAGMA user_version` + 顺序迁移数组管理(新增列/表只追加迁移项)
- 会话列表/搜索使用摘要查询(`getSessionSummaries` / `searchSessions` 单条 SQL,含消息计数),不加载消息正文;导出走 `getAllSessionsData` 一次 IPC 取回全部行(渲染端不再 N+1 往返)
+- 消息保存采用**差量同步**:新增消息批量插入,被裁剪消息(/undo、/retry、/compress)通过 `db:deleteMessages` 差量删除落库,重启后不会"复活"
### 5.4 Harness Engineering 体系
diff --git a/package-lock.json b/package-lock.json
index c8297b1..5f9fc40 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "metona-ollama-desktop",
- "version": "0.17.1",
+ "version": "0.17.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "metona-ollama-desktop",
- "version": "0.17.1",
+ "version": "0.17.2",
"license": "MIT",
"dependencies": {
"ffmpeg-static": "^5.2.0",
diff --git a/package.json b/package.json
index c149554..19f07b6 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "metona-ollama-desktop",
- "version": "0.17.1",
+ "version": "0.17.2",
"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 f4a69f1..1738ff6 100644
--- a/src/main/db/sqlite.ts
+++ b/src/main/db/sqlite.ts
@@ -424,6 +424,34 @@ export function getMessagesBySession(sessionId: string): MessageRow[] {
return queryAll(getDb(), 'SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC', [sessionId]) as unknown as MessageRow[];
}
+/** 获取会话全部消息 ID(渲染端做差量同步的基线) */
+export function getMessageIds(sessionId: string): string[] {
+ return queryAll(getDb(), 'SELECT id FROM messages WHERE session_id = ?', [sessionId])
+ .map(r => String(r.id));
+}
+
+/**
+ * 删除会话中指定 ID 的消息(分块 IN 子句,单事务)。
+ * 用于 /undo、/retry、/compress 等裁剪会话消息后同步落库,
+ * 防止被移除的消息在重载后"复活"。
+ */
+export function deleteMessages(sessionId: string, ids: string[]): number {
+ const validIds = ids.filter(id => typeof id === 'string' && id.length > 0);
+ if (validIds.length === 0) return 0;
+ const d = getDb();
+ const CHUNK = 500;
+ runTransaction(d, () => {
+ for (let i = 0; i < validIds.length; i += CHUNK) {
+ const chunk = validIds.slice(i, i + CHUNK);
+ const placeholders = chunk.map(() => '?').join(',');
+ runExec(d, `DELETE FROM messages WHERE session_id = ? AND id IN (${placeholders})`,
+ [sessionId, ...chunk]);
+ }
+ });
+ schedulePersist();
+ return validIds.length;
+}
+
// ─── Settings CRUD ───
export function saveSetting(key: string, value: unknown): void {
diff --git a/src/main/ipc.ts b/src/main/ipc.ts
index 696ff4e..6f780b8 100644
--- a/src/main/ipc.ts
+++ b/src/main/ipc.ts
@@ -10,7 +10,7 @@ import { mainWindow } from './main.js';
import { showNotification } from './utils.js';
import {
initDatabase, saveSession, getSession, getAllSessions, deleteSession, clearAllSessions,
- saveMessage, saveMessagesBatch, getMessagesBySession,
+ saveMessage, saveMessagesBatch, getMessagesBySession, getMessageIds, deleteMessages,
saveSetting, getSetting, saveSettingsBatch,
saveTrace, saveTracesBatch, getTracesBySession,
exportAllSessions, importSessions,
@@ -359,6 +359,16 @@ export async function setupIPC(): Promise {
catch { return []; }
});
+ // ── 消息差量同步(/undo、/retry、/compress 裁剪会话消息后删除落库)──
+ ipcMain.handle('db:getMessageIds', (_, sessionId: string) => {
+ try { return getMessageIds(String(sessionId || '')); }
+ catch { return []; }
+ });
+ ipcMain.handle('db:deleteMessages', (_, sessionId: string, ids: string[]) => {
+ try { return { success: true, deleted: deleteMessages(String(sessionId || ''), Array.isArray(ids) ? ids.map(String) : []) }; }
+ catch (err) { return { success: false, error: (err as Error).message }; }
+ });
+
// ── 会话摘要(单条 SQL,历史列表/搜索不再全量加载消息)──
ipcMain.handle('db:getSessionSummaries', () => {
try { return getSessionSummaries(); }
diff --git a/src/main/menu.ts b/src/main/menu.ts
index d47341c..e7e36eb 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.17.1',
+ message: 'Metona Ollama Desktop v0.17.2',
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
icon: getIconPath()
});
diff --git a/src/main/preload.ts b/src/main/preload.ts
index 3281ce2..2ffb6ff 100644
--- a/src/main/preload.ts
+++ b/src/main/preload.ts
@@ -74,6 +74,10 @@ contextBridge.exposeInMainWorld('metonaDesktop', {
saveMessage: (msg: unknown) => ipcRenderer.invoke('db:saveMessage', msg),
saveMessagesBatch: (msgs: unknown[]) => ipcRenderer.invoke('db:saveMessagesBatch', msgs),
getMessages: (sessionId: string) => ipcRenderer.invoke('db:getMessages', sessionId),
+ /** 会话全部消息 ID(差量同步基线) */
+ getMessageIds: (sessionId: string) => ipcRenderer.invoke('db:getMessageIds', sessionId),
+ /** 删除会话中指定 ID 的消息(/undo、/retry、/compress 裁剪后同步落库) */
+ deleteMessages: (sessionId: string, ids: string[]) => ipcRenderer.invoke('db:deleteMessages', sessionId, ids),
saveSetting: (key: string, value: unknown) => ipcRenderer.invoke('db:saveSetting', key, value),
/** 批量保存设置(单事务 + 单次刷盘) */
saveSettingsBatch: (entries: Array<{ key: string; value: unknown }>) => ipcRenderer.invoke('db:saveSettingsBatch', entries),
diff --git a/src/main/tool-handlers-system.ts b/src/main/tool-handlers-system.ts
index bf63c45..f1fc0b2 100644
--- a/src/main/tool-handlers-system.ts
+++ b/src/main/tool-handlers-system.ts
@@ -243,6 +243,9 @@ const FETCH_MAX_RETRIES = 3; // fetch 阶段最多重试 3 次
const FETCH_RETRY_DELAYS = [2000, 4000, 6000]; // 指数退避 (ms),由 jitter() 随机化
/** 自动抓取最大条数硬上限 — 防止上下文爆炸 */
const MAX_AUTO_FETCH = 8;
+/** 自动抓取单页内容字符上限 — 整页全文进结果会造成内存/上下文尖峰,
+ * 最终仅靠 truncateToolResult 的 100k 兜底太晚;搜索场景 8k 字符足够 */
+const AUTO_FETCH_MAX_CHARS = 8000;
/** fetch 全部失败后自动回退到浏览器渲染 */
const BROWSER_FALLBACK_ENABLED = true;
/** 内容过短阈值:小于此字符数且是 HTML 时,自动升级到浏览器渲染 */
@@ -966,7 +969,7 @@ async function applyAutoFetch(result: ToolResult, fetchTop: number, fetchMode: '
const tryFetchOne = async (url: string, title: string): Promise => {
if (fetchedUrls.has(url)) return false;
try {
- const fetchResult = await handleWebFetch({ url, retry: false });
+ const fetchResult = await handleWebFetch({ url, retry: false, max_chars: AUTO_FETCH_MAX_CHARS });
if (fetchResult.success && fetchResult.content) {
fetched.push({ url, title, content: String(fetchResult.content) });
fetchedUrls.add(url);
diff --git a/src/renderer/components/chat-area.ts b/src/renderer/components/chat-area.ts
index 75a92e0..e8a5806 100644
--- a/src/renderer/components/chat-area.ts
+++ b/src/renderer/components/chat-area.ts
@@ -190,8 +190,8 @@ export function appendMessageDOM(msg: ChatMessage, index: number): void {
div.className = `message ${msg.role}`;
div.dataset.index = String(index);
- const avatar = msg.role === 'user' ? '👤' : '🤖';
- const roleLabel = msg.role === 'user' ? '你' : 'AI';
+ const avatar = msg.compressed ? '📋' : msg.role === 'user' ? '👤' : '🤖';
+ const roleLabel = msg.compressed ? '上下文摘要' : msg.role === 'user' ? '你' : 'AI';
const modelTag = (msg.role === 'assistant' && msg.model)
? `${escapeHtml(msg.model)}`
: '';
@@ -199,7 +199,18 @@ export function appendMessageDOM(msg: ChatMessage, index: number): void {
let contentHtml = '';
let safeContent = (msg.content != null) ? String(msg.content) : '';
- if (msg.role === 'assistant') {
+ if (msg.compressed) {
+ // ── 上下文压缩摘要卡片:可见、可折叠;role:user 保证进入后续模型上下文 ──
+ contentHtml += `
+
+
+
${safeMarkdown(safeContent)}
+
`;
+ } else if (msg.role === 'assistant') {
// ── 系统提示词折叠卡片(仅会话首条 assistant 消息展示,避免重复)──
if (!_sysPromptRendered) {
const sysPrompt = state.get('_lastSystemPrompt', '');
diff --git a/src/renderer/components/input-area.ts b/src/renderer/components/input-area.ts
index 865c570..f5b2523 100644
--- a/src/renderer/components/input-area.ts
+++ b/src/renderer/components/input-area.ts
@@ -16,11 +16,11 @@ import { addToolCard, startToolCard, updateToolCard, clearToolCardsExternal, cle
import { ChatDB } from '../db/chat-db.js';
import { OllamaAPI } from '../api/ollama.js';
import { runAgentLoop } from '../services/agent-engine.js';
-import { formatToolResultForModel } from '../services/result-formatter.js';
+import { buildHistoryMessages } from '../services/history-builder.js';
import { estimateTokens } from '../services/context-manager.js';
import { showToolConfirm } from './tool-confirm-modal.js';
import { logInfo, logStream, logError, logSuccess, logWarn, resetVideoProgress, updateVideoProgress } from '../services/log-service.js';
-import type { ChatSession, ChatMessage, OllamaStreamChunk, OllamaMessage, FileContent, ChatFile, ToolCallRecord, AgentMode } from '../types.js';
+import type { ChatSession, ChatMessage, OllamaStreamChunk, FileContent, ChatFile, ToolCallRecord, AgentMode } from '../types.js';
let chatInputEl: HTMLTextAreaElement;
let btnSendEl: HTMLButtonElement;
@@ -480,10 +480,11 @@ async function handleRetry(): Promise {
return;
}
- // 找到最后一条 user 消息
+ // 找到最后一条 user 消息(跳过压缩摘要——它不是真实的用户输入,不应成为重试目标)
let lastUserIdx = -1;
for (let i = currentSession.messages.length - 1; i >= 0; i--) {
- if (currentSession.messages[i].role === 'user') { lastUserIdx = i; break; }
+ const m = currentSession.messages[i];
+ if (m.role === 'user' && !m.compressed) { lastUserIdx = i; break; }
}
if (lastUserIdx < 0) { showToast('没有找到用户消息', 'warning'); return; }
@@ -512,13 +513,8 @@ async function handleRetry(): Promise {
updateSendButton(true);
// 始终走 Agent Loop
- // ── 构建历史消息(不含最后一条用户消息,因为它是重试目标)──
- const historyMessages = buildHistoryMessages(
- currentSession.messages
- .filter(m => m.role === 'user' || m.role === 'assistant')
- .slice(0, -1), // 不包含刚保留的最后一条 user 消息
- 30
- );
+ // ── 构建历史消息:history-builder 自动排除末尾的当前用户消息(重试目标)──
+ const historyMessages = buildHistoryMessages(currentSession.messages, 30);
let retryMonitor: ReturnType | null = null;
@@ -554,9 +550,13 @@ async function handleRetry(): Promise {
});
},
onNewIteration: (toolCalls, stats) => {
- removeCurrentPlaceholder();
- const hasContent = !!retryContent?.trim();
- if (hasContent || retryIterations === 0) {
+ // 与发送路径一致:完全空白的上一轮跳过入库(旧的 retryIterations===0 条件
+ // 会在首轮迭代产生无内容的幽灵 assistant 消息)
+ const hasPrevPayload = !!(retryContent || '').trim()
+ || !!(retryThinkContent || '').trim()
+ || retryIterationToolRecords.length > 0
+ || !!(stats?.eval_count || stats?.prompt_eval_count);
+ if (hasPrevPayload) {
const now = Date.now();
const prevMsg: ChatMessage = {
role: 'assistant', content: retryContent || '', model: getSelectedModel(),
@@ -568,14 +568,16 @@ async function handleRetry(): Promise {
...(stats?.prompt_eval_count && { prompt_eval_count: stats.prompt_eval_count }),
...(stats?.total_duration && { total_duration: stats.total_duration }),
};
- retryIterationToolRecords = [];
state.update(KEYS.CURRENT_SESSION, (s: any) => ({
...s, messages: [...s.messages, prevMsg], updatedAt: Date.now()
}));
renderMessages();
}
+ retryIterationToolRecords = [];
+ removeCurrentPlaceholder();
appendAssistantPlaceholder();
retryContent = '';
+ retryThinkContent = '';
retryIterations++;
},
onThinkingStart: () => {
@@ -691,8 +693,9 @@ async function handleUndo(): Promise {
let removeEnd = msgs.length;
let removeStart = msgs.length - 1;
- // 找到最后一条 user 消息的位置
- while (removeStart >= 0 && msgs[removeStart].role !== 'user') {
+ // 找到最后一条 user 消息的位置(跳过压缩摘要——撤销应针对真实用户输入,
+ // 摘要随其后一并删除)
+ while (removeStart >= 0 && (msgs[removeStart].role !== 'user' || msgs[removeStart].compressed)) {
removeStart--;
}
if (removeStart < 0) { showToast('没有找到用户消息', 'warning'); return; }
@@ -779,8 +782,10 @@ async function handleCompress(): Promise {
}
// 构建压缩后的摘要消息(标记 compressed)
+ // role 必须为 user:system 消息既不渲染也不进入 buildHistoryMessages,
+ // 会导致手动压缩完全无效;与 compressWithLLM 的 C5 规则保持一致
const summaryMsg: ChatMessage = {
- role: 'system',
+ role: 'user',
content: `📋 以下是对之前对话的摘要(已压缩 ${uncompressedMiddle.length} 条消息):\n\n${summary}`,
timestamp: Date.now(),
compressed: true
@@ -951,76 +956,8 @@ function buildFileContentParts(fileContents: Array<{ name: string; language: str
}).filter(Boolean);
}
-/**
- * 从会话消息构建 Ollama 格式的历史消息列表。
- * 注入 assistant + user(含 _apiContent)+ tool_calls + role:'tool' 结果。
- * 不注入 system 消息——保证传给 API 时始终只有 1 条 system 且排在首位。
- */
-function buildHistoryMessages(msgs: ChatMessage[], maxCount = 20): OllamaMessage[] {
- const result: OllamaMessage[] = [];
-
- for (const msg of msgs) {
- if (msg.role !== 'assistant' && msg.role !== 'user') continue;
- if (result.length >= maxCount) break;
-
- if (msg.role === 'user') {
- // 用 _apiContent(含附件 JSON 结构化数据),没有则回退 content
- const content = (msg as any)._apiContent || msg.content || '';
- result.push({ role: 'user', content, ...(msg.images?.length && { images: msg.images }) });
- continue;
- }
-
- if (msg.role === 'assistant') {
- const content = msg.content || '';
- const assistantMsg: OllamaMessage = {
- role: 'assistant',
- content,
- ...(msg.think && { thinking: msg.think }),
- ...(msg.images?.length && { images: msg.images }),
- };
-
- // ── 注入 tool_calls(Ollama 格式)──
- if (msg.toolCalls?.length) {
- assistantMsg.tool_calls = msg.toolCalls.map(tc => ({
- type: 'function' as const,
- function: {
- name: tc.name,
- arguments: tc.arguments,
- },
- }));
- }
-
- result.push(assistantMsg);
-
- // ── 注入 tool 结果消息(role: 'tool')──
- // 复用 formatToolResultForModel 保持与当前 Loop 格式一致
- if (msg.toolCalls?.length) {
- for (const tc of msg.toolCalls) {
- if (!tc.result) continue;
- if (result.length >= maxCount) break;
- const formattedResult = formatToolResultForModel(tc.name, tc.result);
- // R92: 工具结果格式标准化 — 添加统一头信息 + 数据边界标记
- const resultDuration = Date.now() - tc.timestamp;
- const resultSize = formattedResult.length;
- const sizeCategory = resultSize > 10000 ? 'large' : resultSize > 2000 ? 'medium' : 'small';
- const r92Header = `[工具:${tc.name} 状态:${tc.status} 耗时:${resultDuration}ms 大小:${sizeCategory}(${resultSize}字符)]`;
- result.push({
- role: 'tool',
- tool_name: tc.name,
- content: `<<>>\n${r92Header}\n${formattedResult}\n<<>>`,
- });
- }
- }
- }
- }
-
- // 超过 maxCount 从尾部截取
- if (result.length > maxCount) {
- return result.slice(-maxCount);
- }
-
- return result;
-}
+// buildHistoryMessages 已抽取为独立纯函数模块 history-builder.ts
+// (发送/重试共用唯一实现,当前轮输入由函数契约自动排除,便于单元测试)
export async function sendMessage(): Promise {
const text = chatInputEl.value.trim();
@@ -1203,10 +1140,9 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
// 构建历史消息
// ── 构建历史消息(含工具调用和结果,消除跨 Loop 上下文断裂)──
- const historyMessages = buildHistoryMessages(
- freshSession.messages.filter(m => m.role === 'user' || m.role === 'assistant'),
- 30 // 预留空间给 tool 结果消息,实际有效轮次仍约 20 轮
- );
+ // history-builder 自动排除末尾的当前用户消息(由 handleInit 以
+ // userContent/images 重新注入),否则用户消息与图片会被发送两份
+ const historyMessages = buildHistoryMessages(freshSession.messages, 30);
let assistantContent = '';
let thinkContent = '';
@@ -1232,27 +1168,34 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
});
},
onNewIteration: (toolCalls, stats) => {
- // 保存上一轮的卡片(含工具记录)
- const prevMsg: ChatMessage = {
- role: 'assistant',
- content: assistantContent || '',
- model: getSelectedModel(),
- timestamp: Date.now(),
- ...(thinkContent && { think: thinkContent }),
- ...(currentIterationToolRecords.length > 0 && { toolCalls: [...currentIterationToolRecords] }),
- // P0 修复:中间迭代消息携带本轮独立 token 统计
- ...(stats?.eval_count && { eval_count: stats.eval_count }),
- ...(stats?.prompt_eval_count && { prompt_eval_count: stats.prompt_eval_count }),
- ...(stats?.total_duration && { total_duration: stats.total_duration }),
- };
+ // 保存上一轮的卡片(含工具记录)。
+ // 完全空白的上一轮(无内容/思考/工具/统计)跳过入库,避免幽灵消息污染会话与 DB
+ const hasPrevPayload = !!(assistantContent || '').trim()
+ || !!(thinkContent || '').trim()
+ || currentIterationToolRecords.length > 0
+ || !!(stats?.eval_count || stats?.prompt_eval_count);
+ if (hasPrevPayload) {
+ const prevMsg: ChatMessage = {
+ role: 'assistant',
+ content: assistantContent || '',
+ model: getSelectedModel(),
+ timestamp: Date.now(),
+ ...(thinkContent && { think: thinkContent }),
+ ...(currentIterationToolRecords.length > 0 && { toolCalls: [...currentIterationToolRecords] }),
+ // P0 修复:中间迭代消息携带本轮独立 token 统计
+ ...(stats?.eval_count && { eval_count: stats.eval_count }),
+ ...(stats?.prompt_eval_count && { prompt_eval_count: stats.prompt_eval_count }),
+ ...(stats?.total_duration && { total_duration: stats.total_duration }),
+ };
+ state.update(KEYS.CURRENT_SESSION, (session: any) => ({
+ ...session,
+ messages: [...session.messages, prevMsg],
+ updatedAt: Date.now()
+ }));
+ // P0-P1 修复:中间迭代消息必须持久化,否则崩溃时前几轮的 assistant 消息全丢失
+ saveCurrentSession().catch(e => logError('onNewIteration 保存失败', String(e)));
+ }
currentIterationToolRecords = [];
- state.update(KEYS.CURRENT_SESSION, (session: any) => ({
- ...session,
- messages: [...session.messages, prevMsg],
- updatedAt: Date.now()
- }));
- // P0-P1 修复:中间迭代消息必须持久化,否则崩溃时前几轮的 assistant 消息全丢失
- saveCurrentSession().catch(e => logError('onNewIteration 保存失败', String(e)));
// P1-R3 修复:先移除旧 placeholder → 渲染历史 → 创建新 placeholder,避免渲染空隙
removeCurrentPlaceholder();
renderMessages();
@@ -1367,19 +1310,25 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
const abortToolRecords = state.get('_abortToolRecords', null);
if (abortToolRecords) state.set('_abortToolRecords', null);
- const partialMsg: ChatMessage = {
- role: 'assistant',
- content: assistantContent,
- timestamp: Date.now(),
- ...(thinkContent && { think: thinkContent }),
- ...(abortToolRecords?.length && { toolCalls: abortToolRecords }),
- stopped: true
- };
- state.update(KEYS.CURRENT_SESSION, (session: any) => ({
- ...session,
- messages: [...session.messages, partialMsg],
- updatedAt: Date.now()
- }));
+ // 与 onNewIteration 一致:完全空白的 partial 消息不入库,避免幽灵行
+ const hasPartialPayload = !!(assistantContent || '').trim()
+ || !!(thinkContent || '').trim()
+ || !!abortToolRecords?.length;
+ if (hasPartialPayload) {
+ const partialMsg: ChatMessage = {
+ role: 'assistant',
+ content: assistantContent,
+ timestamp: Date.now(),
+ ...(thinkContent && { think: thinkContent }),
+ ...(abortToolRecords?.length && { toolCalls: abortToolRecords }),
+ stopped: true
+ };
+ state.update(KEYS.CURRENT_SESSION, (session: any) => ({
+ ...session,
+ messages: [...session.messages, partialMsg],
+ updatedAt: Date.now()
+ }));
+ }
appendSystemMessage('⏹ 已停止生成');
await saveCurrentSession();
} else {
diff --git a/src/renderer/db/chat-db.ts b/src/renderer/db/chat-db.ts
index b3fea87..7da8f27 100644
--- a/src/renderer/db/chat-db.ts
+++ b/src/renderer/db/chat-db.ts
@@ -3,7 +3,10 @@
* v4.0: 桌面端走 IPC → SQLite,Web 端仍用 IndexedDB
*/
-import type { ChatSession } from '../types.js';
+import type { ChatSession, ChatFile, ChatMessage } from '../types.js';
+
+/** 消息级视频元数据(_videos 字段结构) */
+interface VideoMeta { fileName: string; frameCount: number; duration: number }
/** 检查是否在桌面环境 */
function isDesktop(): boolean {
@@ -69,8 +72,48 @@ export class ChatDB {
// ── Sessions ──
- // P1-P1 修复:追踪已保存的消息 ID,避免每次 saveSession 全量重写所有消息
- private _savedMsgIds = new Set();
+ // P1-P1 修复:追踪已保存的消息 ID,避免每次 saveSession 全量重写所有消息。
+ // 0.17.2:按会话追踪 + 差量删除——/undo、/retry、/compress 裁剪掉的消息
+ // 同步从 SQLite 删除,防止重载后"复活"或新旧消息交织。
+ private _savedIdsBySession = new Map>();
+
+ /** 会话消息的持久化 ID(确定性:时间戳+角色+索引,同一消息跨次保存 ID 不变) */
+ private _messageRowId(sessionId: string, msg: ChatMessage, index: number): string {
+ return `${sessionId}_${msg.timestamp}_${msg.role}_${index}`;
+ }
+
+ /** 会话消息行 → SQLite 行(attachments 携带文件/视频/压缩摘要标记) */
+ private _messageToRow(sessionId: string, msg: ChatMessage, index: number): Record {
+ return {
+ id: this._messageRowId(sessionId, msg, index),
+ session_id: sessionId,
+ role: msg.role,
+ content: msg.content || null,
+ thinking: msg.think || null,
+ images: msg.images?.length ? JSON.stringify(msg.images) : null,
+ tool_calls: msg.toolCalls?.length ? JSON.stringify(msg.toolCalls) : null,
+ tool_name: null,
+ attachments: JSON.stringify({
+ ...(msg.files?.length && { files: msg.files }),
+ ...((msg as { _videos?: VideoMeta[] })._videos?.length && { videos: (msg as { _videos?: VideoMeta[] })._videos }),
+ ...(msg.compressed && { compressed: true }),
+ }),
+ eval_count: msg.eval_count || null,
+ prompt_eval_count: msg.prompt_eval_count || null,
+ total_duration: msg.total_duration || null,
+ created_at: msg.timestamp
+ };
+ }
+
+ /** 解析 attachments JSON(容错) */
+ private _parseAttachments(raw: string | null): { files?: ChatFile[]; videos?: Array<{ fileName: string; frameCount: number; duration: number }>; compressed?: boolean } {
+ try {
+ const a = JSON.parse(raw || '{}');
+ return { files: a.files, videos: a.videos, compressed: a.compressed === true };
+ } catch {
+ return {};
+ }
+ }
async saveSession(session: ChatSession): Promise {
if (isDesktop()) {
@@ -85,32 +128,46 @@ export class ChatDB {
updated_at: session.updatedAt
};
await dbBridge().saveSession(row);
- // 只保存新增消息(不在 _savedMsgIds 中的),并合并为一次批量 IPC + 单事务
+
+ // ── 差量同步:插入新增消息 + 删除被裁剪的消息 ──
+ let saved = this._savedIdsBySession.get(session.id);
+ if (!saved) {
+ // 首次接触该会话(应用重启后 / 加载历史):以 DB 现有行 ID 为基线,
+ // 否则之前运行遗留的已裁剪行无法被 diff 出来
+ saved = new Set(await dbBridge().getMessageIds(session.id));
+ }
+
+ const currentIds: string[] = [];
const newMsgs: Array> = [];
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);
- newMsgs.push({
- id: msgId,
- session_id: session.id,
- role: msg.role,
- content: msg.content || null,
- thinking: msg.think || null,
- images: msg.images?.length ? JSON.stringify(msg.images) : null,
- tool_calls: msg.toolCalls?.length ? JSON.stringify(msg.toolCalls) : null,
- tool_name: null,
- attachments: JSON.stringify({ files: msg.files, videos: (msg as any)._videos }),
- eval_count: msg.eval_count || null,
- prompt_eval_count: msg.prompt_eval_count || null,
- total_duration: msg.total_duration || null,
- created_at: msg.timestamp
- });
+ const msgId = this._messageRowId(session.id, msg, mi);
+ currentIds.push(msgId);
+ if (saved.has(msgId)) continue;
+ newMsgs.push(this._messageToRow(session.id, msg, mi));
+ }
+
+ const currentIdSet = new Set(currentIds);
+ const removedIds = [...saved].filter(id => !currentIdSet.has(id));
+ let deleteOk = true;
+ if (removedIds.length > 0) {
+ const delResult = await dbBridge().deleteMessages(session.id, removedIds);
+ deleteOk = !delResult || delResult.success !== false;
}
if (newMsgs.length > 0) {
await dbBridge().saveMessagesBatch(newMsgs as never[]);
}
+
+ if (deleteOk) {
+ // 同步成功才提交追踪集
+ this._savedIdsBySession.set(session.id, currentIdSet);
+ } else {
+ // 删除失败:把被删 ID 保留在追踪集中,下次保存时重试差量删除,
+ // 否则这些行将永远失去清理机会(重载后"复活")
+ const retrySet = new Set(currentIdSet);
+ for (const id of removedIds) retrySet.add(id);
+ this._savedIdsBySession.set(session.id, retrySet);
+ }
return session.id;
}
return this._idbSaveSession(session);
@@ -118,7 +175,7 @@ export class ChatDB {
/** P1-P1 修复:切换会话时重置已保存消息追踪,释放内存 */
resetSavedMsgTracking(): void {
- this._savedMsgIds.clear();
+ this._savedIdsBySession.clear();
}
async getSession(id: string): Promise {
@@ -127,10 +184,12 @@ export class ChatDB {
if (!row) return null;
const msgRows = await dbBridge().getMessages(id);
const messages = msgRows.map((r: any) => {
- let files, videos;
- try { const a = JSON.parse(r.attachments || '{}'); files = a.files; videos = a.videos; } catch { /* ignore */ }
+ const att = this._parseAttachments(r.attachments);
+ // 旧版 /compress 曾以 role:'system' 存储摘要(不渲染也不进入后续上下文),
+ // 读取时归一化为 user + compressed,保存后自愈
+ const legacySummary = r.role === 'system';
return {
- role: r.role,
+ role: legacySummary ? 'user' : r.role,
content: r.content || '',
timestamp: r.created_at,
think: r.thinking || undefined,
@@ -139,8 +198,9 @@ export class ChatDB {
prompt_eval_count: r.prompt_eval_count || undefined,
total_duration: r.total_duration || undefined,
toolCalls: r.tool_calls ? JSON.parse(r.tool_calls) : undefined,
- ...(files?.length && { files }),
- ...(videos?.length && { _videos: videos })
+ ...((legacySummary || att.compressed) && { compressed: true as const }),
+ ...(att.files?.length && { files: att.files }),
+ ...(att.videos?.length && { _videos: att.videos })
};
});
return {
@@ -168,10 +228,11 @@ export class ChatDB {
for (const row of data.sessions) {
const msgRows = bySession.get(row.id) || [];
const messages = msgRows.map((r: any) => {
- let files, videos;
- try { const a = JSON.parse(r.attachments || '{}'); files = a.files; videos = a.videos; } catch { /* ignore */ }
+ const att = this._parseAttachments(r.attachments);
+ // 与 getSession 一致:旧版 system 摘要行归一化为 user + compressed
+ const legacySummary = r.role === 'system';
return {
- role: r.role,
+ role: legacySummary ? 'user' : r.role,
content: r.content || '',
timestamp: r.created_at,
think: r.thinking || undefined,
@@ -180,8 +241,9 @@ export class ChatDB {
prompt_eval_count: r.prompt_eval_count || undefined,
total_duration: r.total_duration || undefined,
toolCalls: r.tool_calls ? JSON.parse(r.tool_calls) : undefined,
- ...(files?.length && { files }),
- ...(videos?.length && { _videos: videos })
+ ...((legacySummary || att.compressed) && { compressed: true as const }),
+ ...(att.files?.length && { files: att.files }),
+ ...(att.videos?.length && { _videos: att.videos })
};
});
sessions.push({
@@ -256,7 +318,14 @@ export class ChatDB {
thinking: m.think || null,
images: m.images?.length ? JSON.stringify(m.images) : null,
tool_calls: m.toolCalls?.length ? JSON.stringify(m.toolCalls) : null,
- tool_name: null, eval_count: m.eval_count || null,
+ tool_name: null,
+ // 导入路径同样携带 attachments,避免备份恢复丢失文件/视频/摘要标记
+ attachments: JSON.stringify({
+ ...(m.files?.length && { files: m.files }),
+ ...((m as { _videos?: VideoMeta[] })._videos?.length && { videos: (m as { _videos?: VideoMeta[] })._videos }),
+ ...(m.compressed && { compressed: true }),
+ }),
+ eval_count: m.eval_count || null,
prompt_eval_count: m.prompt_eval_count || null,
total_duration: m.total_duration || null, created_at: m.timestamp
}))),
diff --git a/src/renderer/index.html b/src/renderer/index.html
index a30b4fe..0df017a 100644
--- a/src/renderer/index.html
+++ b/src/renderer/index.html
@@ -28,7 +28,7 @@
-
调整各通信层超时时长。留空则使用默认值(HTTP:30s / 流式:300s / MCP:60s)。设为 0 可禁用超时。
+
调整各通信层超时时长。留空则使用默认值(HTTP:900s / 流式:动态 / MCP:60s)。设为 0 可禁用超时。
@@ -471,11 +471,11 @@