From 0903d740dacb41be683ef7bc56771738391d8ecb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=B4=AB=E5=BD=B1233?=
Date: Wed, 24 Jun 2026 14:49:09 +0800
Subject: [PATCH] =?UTF-8?q?v0.13.0:=20=E8=AE=B0=E5=BF=86=E7=B3=BB=E7=BB=9F?=
=?UTF-8?q?=E9=87=8D=E6=9E=84=20=E2=80=94=20MEMORY.md=20=E6=96=87=E4=BB=B6?=
=?UTF-8?q?=E5=8C=96=20+=20=E8=B7=AF=E5=BE=84=E4=BF=9D=E6=8A=A4=20+=20?=
=?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=8F=90=E5=8F=96=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
核心变更:
- 删除 SQLite memories 表 + FTS5 + IVF 向量存储引擎 (~1300行)
- 4 个记忆工具合并为 1 个 memory 工具 (5 action)
- 记忆存储改为工作空间 MEMORY.md 单文件,严格格式校验
- 路径保护: checkPathAllowed 拦截所有工具,仅 memory 专用 IPC 通道可访问
- 应用启动/工作空间切换时自动校验并初始化 MEMORY.md(格式错误自动备份重建)
- 自动记忆提取重建: 对话结束时触发,多层质量过滤(内容/泛化/去重/安全/importance门槛)
- 工具总数: 42→40,UI 全面更新(帮助面板、工具面板、设置面板、README)
- 版本号更新: 0.12.11 → 0.13.0
---
README.md | 42 +-
package-lock.json | 4 +-
package.json | 2 +-
src/main/db/sqlite.ts | 143 +---
src/main/ipc.ts | 161 +++-
src/main/menu.ts | 2 +-
src/main/preload.ts | 12 +-
src/main/tool-security.ts | 27 +
src/renderer/components/chat-area.ts | 4 +-
src/renderer/components/input-area.ts | 1 -
src/renderer/components/memory-modal.ts | 427 ++---------
src/renderer/components/settings-modal.ts | 93 +--
src/renderer/components/workspace-panel.ts | 6 +-
src/renderer/db/chat-db.ts | 138 +---
src/renderer/index.html | 71 +-
src/renderer/main.ts | 15 +-
src/renderer/public/AGENT.md | 8 +-
src/renderer/services/agent-engine.ts | 55 +-
src/renderer/services/completion-gate.ts | 4 +-
src/renderer/services/memory-manager.ts | 751 -------------------
src/renderer/services/memory-service.ts | 809 +++++++++++++++++++++
src/renderer/services/sub-agent.ts | 2 +-
src/renderer/services/tool-registry.ts | 160 ++--
src/renderer/services/vector-memory.ts | 172 -----
src/renderer/services/vector-store.ts | 371 ----------
src/renderer/types.d.ts | 91 +--
26 files changed, 1207 insertions(+), 2364 deletions(-)
delete mode 100644 src/renderer/services/memory-manager.ts
create mode 100644 src/renderer/services/memory-service.ts
delete mode 100644 src/renderer/services/vector-memory.ts
delete mode 100644 src/renderer/services/vector-store.ts
diff --git a/README.md b/README.md
index af794ce..1303128 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -37,8 +37,8 @@
| 🤖 | **ReAct Agent Loop** | 始终开启的唯一对话模式。8 状态机(INIT→THINKING→PARSING→EXECUTING→OBSERVING→REFLECTING→COMPRESSING→TERMINATED),最大 85 轮(可配置),自动重试 2 次,工具去重,并行/链式执行,看门狗超时保护 |
| 🛡️ | **5 层抗幻觉系统** | 提示词加固 → 任务感知 → 中途检测(16 条规则覆盖全部工具类别)→ 进度锚点 → 完成闸门(6 项检查,幻觉/注入/文件/工具 4 类关键阻断) |
| 📋 | **Plan Mode** | 开关切换,AI 首先生成执行计划(Markdown 渲染确认弹窗),批准后按步骤追踪执行,plan_track 工具标记完成状态 |
-| 🔧 | **42 个内置工具** | 文件系统(16个) · 命令执行 · 联网搜索 · 浏览器控制(9个) · Git · 记忆 · 会话 · 子代理 · 系统工具 · Plan Mode 追踪 |
-| 🧠 | **智能记忆系统** | 三类记忆(fact / preference / rule),FTS5 全文搜索 + 向量语义搜索,写入前安全扫描,容量 500 条,90 天衰减 |
+| 🔧 | **40 个内置工具** | 文件系统(16个) · 命令执行 · 联网搜索 · 浏览器控制(9个) · Git · 记忆 · 会话 · 子代理 · 系统工具 · Plan Mode 追踪 |
+| 🧠 | **智能记忆系统** | 三类记忆(fact / preference / rule),存储于工作空间 MEMORY.md 文件,受路径保护仅 memory 工具可访问,写入前安全扫描,容量 500 条,对话结束自动提取,严格格式校验 |
| 📋 | **自定义文件** | SOUL.md(人格,不可压缩)+ AGENT.md(行为准则)+ USER.md(用户画像),工作空间优先,内置默认 fallback |
| 🌐 | **MCP 协议扩展** | JSON-RPC 2.0 over stdio,动态工具发现,Shadowing 防护 |
| 🔍 | **联网搜索(双模式)** | SearXNG 元搜索引擎 JSON API(70+引擎聚合)/ 四引擎 HTML 解析(Bing+百度+搜狗+360),双模式可切换;web_fetch 支持反爬+UA切换+浏览器回退 |
@@ -129,14 +129,11 @@
-🧠 记忆管理(4 个)
+🧠 记忆管理(1 个)
| 工具 | 功能 |
|------|------|
-| `memory_search` | 搜索记忆(FTS5 + 向量语义) |
-| `memory_add` | 添加记忆(自动安全扫描) |
-| `memory_replace` | 修改记忆 |
-| `memory_remove` | 删除记忆 |
+| `memory` | 统一记忆管理(5 个 action:search 搜索 / add 添加 / replace 替换 / remove 删除 / read_all 读取全部)。工作空间 MEMORY.md 受路径保护,仅此工具可访问 |
@@ -179,7 +176,7 @@
```
用户消息 → 扫描工作空间 SOUL.md(不可压缩层)→ AGENT.md → USER.md
↓
- 记忆检索 (FTS5 + 向量语义搜索) → 上下文注入
+ 记忆检索 (MEMORY.md 关键词搜索) → 上下文注入
↓
Agent Engine (8 状态机 ReAct Loop, ≤85 轮, 看门狗 30min)
↓
@@ -214,14 +211,13 @@ main.ts (入口)
### 🗄️ 数据库
-SQLite (sql.js WASM),6 张表,WAL 模式 + FTS5 全文搜索:
+SQLite (sql.js WASM),5 张表,WAL 模式:
| 表 | 用途 |
|---|---|
| `sessions` | 会话(parent_id 父子关系) |
| `messages` | 消息(外键级联删除) |
| `tool_calls` | 工具调用记录 |
-| `memories` | Agent 记忆(FTS5 + 向量嵌入) |
| `settings` | 设置(JSON 序列化) |
| `traces` | ReAct 执行轨迹 |
@@ -234,7 +230,7 @@ SQLite (sql.js WASM),6 张表,WAL 模式 + FTS5 全文搜索:
| 🖥️ 前端渲染 | HTML 净化器(白名单标签 + URI 协议检查) |
| 🔒 Electron | `contextIsolation: true` + IPC 白名单 + IPC fs 路径验证 |
| 🔐 数据加密 | AES-256-GCM |
-| 🧠 记忆安全 | 写入前 Prompt Injection / 敏感信息检测 |
+| 🧠 记忆安全 | 写入前 Prompt Injection / 敏感信息检测,多层级去重过滤 |
| 🌐 MCP 安全 | Shadowing 防护 + 双下划线分隔防歧义 |
| 🌐 网络安全 | `web_fetch` 流式体积限制(10MB)+ 无 content-length 时防 OOM |
| ⚡ 进程安全 | Windows `taskkill` 强制终止 + 工作空间路径大小写不敏感校验 |
@@ -256,7 +252,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
```
-产出:`release/Metona Ollama Setup v0.12.11.exe`
+产出:`release/Metona Ollama Setup v0.13.0.exe`
## 🛠️ 常用命令
@@ -289,8 +285,8 @@ npm run dist # 构建 Windows 安装包
| 🤖 | **ReAct Agent Loop** | Always-on, only chat mode. 8-state machine (INIT→THINKING→PARSING→EXECUTING→OBSERVING→REFLECTING→COMPRESSING→TERMINATED), up to 85 iterations (configurable), auto-retry 2 times, tool dedup, parallel/chain execution, watchdog timeout protection |
| 🛡️ | **5-Layer Anti-Hallucination** | Prompt hardening → task awareness → mid-task detection (16 rules covering all tool categories) → progress anchors → completion gate (6 checks: hallucination, injection, file, tool — 4 critical blocks) |
| 📋 | **Plan Mode** | Toggle switch. AI first generates an execution plan (Markdown-rendered confirmation dialog), then tracks step-by-step execution, with plan_track tool marking completion |
-| 🔧 | **42 Built-in Tools** | File system · Command · Web search · Browser · Git · Memory · Sessions · Sub-agent · System · Plan Mode tracking |
-| 🧠 | **Smart Memory System** | Three types (fact / preference / rule), FTS5 + vector semantic search, pre-write security scan, 500 capacity, 90-day decay |
+| 🔧 | **40 Built-in Tools** | File system · Command · Web search · Browser · Git · Memory · Sessions · Sub-agent · System · Plan Mode tracking |
+| 🧠 | **Smart Memory System** | Three types (fact / preference / rule), stored in workspace MEMORY.md file, path-protected — only the memory tool can access it, pre-write security scan, 500 capacity, auto-extract on conversation end, strict format validation |
| 📋 | **Custom Files** | SOUL.md (persona, never compressed) + AGENT.md (behavior rules) + USER.md (user profile), workspace-first with built-in defaults |
| 🌐 | **MCP Protocol Extension** | JSON-RPC 2.0 over stdio, dynamic tool discovery, Shadowing protection |
| 🔍 | **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 |
@@ -380,14 +376,11 @@ npm run dist # 构建 Windows 安装包
-🧠 Memory Management (4)
+🧠 Memory Management (1)
| Tool | Function |
|------|------|
-| `memory_search` | Search memories (FTS5 + vector semantic) |
-| `memory_add` | Add memory (auto security scan) |
-| `memory_replace` | Modify memory |
-| `memory_remove` | Delete memory |
+| `memory` | Unified memory management (5 actions: search / add / replace / remove / read_all). Workspace MEMORY.md is path-protected, only this tool can access it |
@@ -430,7 +423,7 @@ npm run dist # 构建 Windows 安装包
```
User message → workspace SOUL.md (never compressed) → AGENT.md → USER.md
↓
- Memory Retrieval (FTS5 + Vector Semantic Search) → Context Injection
+ Memory Retrieval (MEMORY.md keyword search) → Context Injection
↓
Agent Engine (8-state ReAct Loop, ≤85 iter, watchdog 30min)
↓
@@ -465,14 +458,13 @@ main.ts (Entry)
### 🗄️ Database
-SQLite (sql.js WASM), 6 tables, WAL mode + FTS5 full-text search:
+SQLite (sql.js WASM), 5 tables, WAL mode:
| Table | Purpose |
|---|---|
| `sessions` | Sessions (parent_id hierarchy) |
| `messages` | Messages (cascade delete) |
| `tool_calls` | Tool call records |
-| `memories` | Agent memories (FTS5 + vector embedding) |
| `settings` | Settings (JSON serialization) |
| `traces` | ReAct execution traces |
@@ -485,7 +477,7 @@ SQLite (sql.js WASM), 6 tables, WAL mode + FTS5 full-text search:
| 🖥️ Frontend Rendering | HTML sanitizer (whitelist tags + URI protocol check) |
| 🔒 Electron | `contextIsolation: true` + IPC whitelist + IPC fs path validation |
| 🔐 Data Encryption | AES-256-GCM |
-| 🧠 Memory Security | Pre-write prompt injection / sensitive info detection |
+| 🧠 Memory Security | Pre-write prompt injection / sensitive info detection, multi-layer dedup filtering |
| 🌐 MCP Security | Shadowing protection + double-underscore delimiter disambiguation |
| 🌐 Network Security | `web_fetch` streaming size limit (10MB) + OOM prevention without content-length |
| ⚡ Process Security | Windows `taskkill` forced termination + case-insensitive workspace path validation |
@@ -507,7 +499,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
```
-Output: `release/Metona Ollama Setup v0.12.11.exe`
+Output: `release/Metona Ollama Setup v0.13.0.exe`
## 🛠️ Common Commands
diff --git a/package-lock.json b/package-lock.json
index 7cedc0e..1e5f8c7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "metona-ollama-desktop",
- "version": "0.12.11",
+ "version": "0.13.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "metona-ollama-desktop",
- "version": "0.12.11",
+ "version": "0.13.0",
"license": "MIT",
"dependencies": {
"ffmpeg-static": "^5.2.0",
diff --git a/package.json b/package.json
index 13d1d7c..408c826 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "metona-ollama-desktop",
- "version": "0.12.11",
+ "version": "0.13.0",
"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 6a0ed1f..89b1c6b 100644
--- a/src/main/db/sqlite.ts
+++ b/src/main/db/sqlite.ts
@@ -73,7 +73,6 @@ function runTransaction(db: SQL.Database, fn: () => void): void {
let db: SQL.Database | null = null;
let dbPath: string | null = null;
-let _hasFTS5 = false;
/** 获取数据库实例 */
export function getDb(): SQL.Database {
@@ -171,24 +170,6 @@ export async function initDatabase(): Promise {
);
CREATE INDEX IF NOT EXISTS idx_tool_calls_session ON tool_calls(session_id, tool_name);
- -- 记忆表
- CREATE TABLE IF NOT EXISTS memories (
- id TEXT PRIMARY KEY,
- type TEXT NOT NULL,
- content TEXT NOT NULL,
- importance INTEGER DEFAULT 5,
- tags TEXT,
- source TEXT,
- session_id TEXT,
- use_count INTEGER DEFAULT 0,
- embedding TEXT,
- created_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL,
- last_used_at INTEGER NOT NULL
- );
- CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(type);
- CREATE INDEX IF NOT EXISTS idx_memories_importance ON memories(importance DESC);
-
-- 设置表
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
@@ -221,21 +202,6 @@ export async function initDatabase(): Promise {
try { db.run('ALTER TABLE traces ADD COLUMN error_pattern TEXT'); } catch { /* 列已存在,忽略 */ }
- // 尝试创建 FTS5 全文搜索(可选,sql.js 默认 WASM 可能不包含 FTS5)
- try {
- db.run(`
- CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
- content, tags, type,
- content='memories',
- content_rowid='rowid'
- );
- `);
- _hasFTS5 = true;
- } catch {
- _hasFTS5 = false;
- // FTS5 不可用时降级为 LIKE 搜索,状态通过 _hasFTS5 跟踪
- }
-
// 写入一次确保文件存在
persist();
@@ -271,20 +237,6 @@ export interface MessageRow {
created_at: number;
}
-export interface MemoryRow {
- id: string;
- type: string;
- content: string;
- importance: number;
- tags: string | null;
- source: string | null;
- session_id: string | null;
- use_count: number;
- embedding: string | null;
- created_at: number;
- updated_at: number;
- last_used_at: number;
-}
export interface SettingRow {
key: string;
@@ -366,93 +318,6 @@ export function getMessagesBySession(sessionId: string): MessageRow[] {
}
-// ─── Memories CRUD ───
-
-export function saveMemory(entry: MemoryRow): string {
- const d = getDb();
- runExec(d, `INSERT OR REPLACE INTO memories (id, type, content, importance, tags, source, session_id, use_count, embedding, created_at, updated_at, last_used_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- [entry.id, entry.type, entry.content, entry.importance, entry.tags,
- entry.source, entry.session_id, entry.use_count, entry.embedding,
- entry.created_at, entry.updated_at, entry.last_used_at]
- );
- // FTS 同步(仅在 FTS5 可用时)
- if (_hasFTS5) {
- try {
- runExec(d, "DELETE FROM memories_fts WHERE rowid IN (SELECT rowid FROM memories WHERE id = ?)", [entry.id]);
- runExec(d, "INSERT INTO memories_fts(rowid, content, tags, type) SELECT rowid, content, tags, type FROM memories WHERE id = ?", [entry.id]);
- } catch { /* FTS 同步失败不影响主流程 */ }
- }
- persist();
- return entry.id;
-}
-
-export function getMemory(id: string): MemoryRow | null {
- return queryOne(getDb(), 'SELECT * FROM memories WHERE id = ?', [id]) as unknown as MemoryRow | null;
-}
-
-export function getAllMemories(): MemoryRow[] {
- return queryAll(getDb(), 'SELECT * FROM memories ORDER BY importance DESC, updated_at DESC') as unknown as MemoryRow[];
-}
-
-export function getMemoriesByType(type: string): MemoryRow[] {
- return queryAll(getDb(), 'SELECT * FROM memories WHERE type = ? ORDER BY importance DESC', [type]) as unknown as MemoryRow[];
-}
-
-export function deleteMemory(id: string): void {
- const d = getDb();
- if (_hasFTS5) {
- try { runExec(d, "DELETE FROM memories_fts WHERE rowid IN (SELECT rowid FROM memories WHERE id = ?)", [id]); } catch { /* ignore */ }
- }
- runExec(d, 'DELETE FROM memories WHERE id = ?', [id]);
- persist();
-}
-
-export function clearAllMemories(): void {
- const d = getDb();
- d.run('DELETE FROM memories');
- if (_hasFTS5) {
- try { d.run('DELETE FROM memories_fts'); } catch { /* ignore */ }
- }
- persist();
-}
-
-export function searchMemoriesFTS(query: string, limit = 10): MemoryRow[] {
- const d = getDb();
-
- // FTS5 不可用时直接走 LIKE
- if (!_hasFTS5) {
- const likeQ = `%${query.replace(/[^\w\u4e00-\u9fff]/g, '%')}%`;
- return queryAll(d, `
- SELECT * FROM memories
- WHERE content LIKE ? OR tags LIKE ?
- ORDER BY importance DESC
- LIMIT ?
- `, [likeQ, likeQ, limit]) as unknown as MemoryRow[];
- }
-
- try {
- // FTS5 搜索
- const ftsQuery = query.replace(/[^\w\u4e00-\u9fff\s]/g, ' ').split(/\s+/).filter(w => w.length > 0).map(w => `"${w}"*`).join(' OR ');
- if (!ftsQuery) return [];
- return queryAll(d, `
- SELECT m.* FROM memories m
- JOIN memories_fts f ON m.rowid = f.rowid
- WHERE memories_fts MATCH ?
- ORDER BY rank
- LIMIT ?
- `, [ftsQuery, limit]) as unknown as MemoryRow[];
- } catch {
- // FTS 失败回退到 LIKE 搜索
- return queryAll(d, `
- SELECT * FROM memories
- WHERE content LIKE ? OR tags LIKE ?
- ORDER BY importance DESC
- LIMIT ?
- `, [`%${query}%`, `%${query}%`, limit]) as unknown as MemoryRow[];
- }
-}
-
// ─── Settings CRUD ───
export function saveSetting(key: string, value: unknown): void {
@@ -571,7 +436,6 @@ export function getAllSessionsTokenStats(): AllSessionsTokenStats {
export interface ExportData {
sessions: SessionRow[];
messages: MessageRow[];
- memories: MemoryRow[];
settings: Array<{ key: string; value: unknown }>;
exportedAt: number;
}
@@ -580,10 +444,9 @@ export function exportAllSessions(): ExportData {
const d = getDb();
const sessions = queryAll(d, 'SELECT * FROM sessions') as unknown as SessionRow[];
const messages = queryAll(d, 'SELECT * FROM messages') as unknown as MessageRow[];
- const memories = queryAll(d, 'SELECT * FROM memories') as unknown as MemoryRow[];
const settingsRows = queryAll(d, 'SELECT * FROM settings') as unknown as SettingRow[];
const settings = settingsRows.map(r => ({ key: r.key, value: (() => { try { return JSON.parse(r.value); } catch { return r.value; } })() }));
- return { sessions, messages, memories, settings, exportedAt: Date.now() };
+ return { sessions, messages, settings, exportedAt: Date.now() };
}
export function importSessions(data: ExportData): { imported: number; skipped: number } {
@@ -602,10 +465,6 @@ export function importSessions(data: ExportData): { imported: number; skipped: n
const sessionExists = queryOne(d, 'SELECT id FROM sessions WHERE id = ?', [msg.session_id]);
if (sessionExists) saveMessage(msg);
}
- for (const mem of data.memories) {
- const existing = queryOne(d, 'SELECT id FROM memories WHERE id = ?', [mem.id]);
- if (!existing) saveMemory(mem);
- }
for (const s of data.settings) {
saveSetting(s.key, s.value);
}
diff --git a/src/main/ipc.ts b/src/main/ipc.ts
index 8e3daf0..a44098a 100644
--- a/src/main/ipc.ts
+++ b/src/main/ipc.ts
@@ -11,7 +11,6 @@ import { showNotification } from './utils.js';
import {
initDatabase, saveSession, getSession, getAllSessions, deleteSession, clearAllSessions,
saveMessage, getMessagesBySession,
- saveMemory, getMemory, getAllMemories, getMemoriesByType, deleteMemory, clearAllMemories, searchMemoriesFTS,
saveSetting, getSetting,
saveToolCall, getToolCallsBySession,
saveTrace, getTracesBySession,
@@ -387,36 +386,6 @@ export async function setupIPC(): Promise {
catch { return []; }
});
- // Memories
- ipcMain.handle('db:saveMemory', (_, entry) => {
- try { return { success: true, id: saveMemory(entry) }; }
- catch (err) { return { success: false, error: (err as Error).message }; }
- });
- ipcMain.handle('db:getMemory', (_, id) => {
- try { return getMemory(id); }
- catch { return null; }
- });
- ipcMain.handle('db:getAllMemories', () => {
- try { return getAllMemories(); }
- catch { return []; }
- });
- ipcMain.handle('db:getMemoriesByType', (_, type) => {
- try { return getMemoriesByType(type); }
- catch { return []; }
- });
- ipcMain.handle('db:deleteMemory', (_, id) => {
- try { deleteMemory(id); return { success: true }; }
- catch (err) { return { success: false, error: (err as Error).message }; }
- });
- ipcMain.handle('db:clearAllMemories', () => {
- try { clearAllMemories(); return { success: true }; }
- catch (err) { return { success: false, error: (err as Error).message }; }
- });
- ipcMain.handle('db:searchMemories', (_, query: string, limit?: number) => {
- try { return searchMemoriesFTS(query, limit ?? 10); }
- catch { return []; }
- });
-
// Settings
ipcMain.handle('db:saveSetting', (_, key: string, value: unknown) => {
try { saveSetting(key, value); return { success: true }; }
@@ -463,6 +432,110 @@ export async function setupIPC(): Promise {
catch (err) { sendLog('error', '获取全局 Token 统计失败', (err as Error).message); return null; }
});
+ // ── Memory 文件访问(专用通道,绕过 checkPathAllowed,仅限 MEMORY.md)──
+ ipcMain.handle('memory:read', async () => {
+ const wsDir = getWorkspaceDir();
+ const memoryPath = path.join(wsDir, 'MEMORY.md');
+ try {
+ if (!fs.existsSync(memoryPath)) {
+ return { success: true, content: '' };
+ }
+ const content = await fs.promises.readFile(memoryPath, 'utf-8');
+ return { success: true, content };
+ } catch (err) {
+ return { success: false, error: (err as Error).message };
+ }
+ });
+
+ ipcMain.handle('memory:write', async (_, content: string) => {
+ const wsDir = getWorkspaceDir();
+ const memoryPath = path.join(wsDir, 'MEMORY.md');
+ try {
+ // 确保工作空间目录存在
+ if (!fs.existsSync(wsDir)) {
+ await fs.promises.mkdir(wsDir, { recursive: true });
+ }
+ if (content === '' || content.trim() === '') {
+ // 空内容 → 删除文件(让下次读取返回空)
+ if (fs.existsSync(memoryPath)) {
+ await fs.promises.unlink(memoryPath);
+ }
+ sendLog('info', '🧠 memory:write', 'MEMORY.md 已清空');
+ return { success: true };
+ }
+ await fs.promises.writeFile(memoryPath, content, 'utf-8');
+ sendLog('success', '🧠 memory:write', `MEMORY.md 已写入 (${content.length} 字符)`);
+ return { success: true };
+ } catch (err) {
+ sendLog('error', '🧠 memory:write 失败', (err as Error).message);
+ return { success: false, error: (err as Error).message };
+ }
+ });
+
+ /** MEMORY.md 初始化:检查 → 校验 → 备份(如格式错误) → 重建 */
+ ipcMain.handle('memory:init', async () => {
+ const wsDir = getWorkspaceDir();
+ const memoryPath = path.join(wsDir, 'MEMORY.md');
+ const result: { action: string; existed: boolean; valid: boolean; backedUp?: string } = {
+ action: '', existed: false, valid: false,
+ };
+
+ try {
+ // 确保工作空间目录存在
+ if (!fs.existsSync(wsDir)) {
+ await fs.promises.mkdir(wsDir, { recursive: true });
+ sendLog('info', '🧠 memory:init', `工作空间目录已创建: ${wsDir}`);
+ }
+
+ const exists = fs.existsSync(memoryPath);
+ result.existed = exists;
+
+ if (!exists) {
+ // 文件不存在 → 创建符合格式的空模板
+ const template = `# METONA MEMORY
+> ⚠️ 此文件由 memory 工具自动管理,严禁手动编辑
+> 元数据格式: ## [类型] | id: [ID] | importance: [1-10] | tags: [tag1, tag2, ...]
+> 条目内容紧跟元数据行,直到下一个 ## 或文件末尾
+
+`;
+ await fs.promises.writeFile(memoryPath, template, 'utf-8');
+ result.action = 'created';
+ sendLog('success', '🧠 MEMORY.md 已创建', `路径: ${memoryPath}`);
+ } else {
+ // 文件存在 → 校验格式
+ const content = await fs.promises.readFile(memoryPath, 'utf-8');
+ const valid = validateMemoryContent(content);
+ result.valid = valid;
+
+ if (!valid) {
+ // 格式不符合 → 备份为 .bak,然后重建
+ const bakPath = memoryPath + '.bak';
+ await fs.promises.copyFile(memoryPath, bakPath);
+ result.backedUp = bakPath;
+
+ const template = `# METONA MEMORY
+> ⚠️ 此文件由 memory 工具自动管理,严禁手动编辑
+> 元数据格式: ## [类型] | id: [ID] | importance: [1-10] | tags: [tag1, tag2, ...]
+> 条目内容紧跟元数据行,直到下一个 ## 或文件末尾
+
+`;
+ await fs.promises.writeFile(memoryPath, template, 'utf-8');
+ result.action = 'recreated';
+ sendLog('warn', '🧠 MEMORY.md 格式不符合规范,已备份并重建',
+ `原文件 → ${bakPath} | 新文件已创建: ${memoryPath}`);
+ } else {
+ result.action = 'valid';
+ sendLog('info', '🧠 MEMORY.md 格式校验通过', `路径: ${memoryPath} | ${content.split('\n').filter(l => /^## (fact|preference|rule)/i.test(l)).length} 条记忆`);
+ }
+ }
+
+ return { success: true, ...result };
+ } catch (err) {
+ sendLog('error', '🧠 memory:init 失败', (err as Error).message);
+ return { success: false, error: (err as Error).message, ...result };
+ }
+ });
+
// ── MCP IPC ──
ipcMain.handle('mcp:startServer', async (_, config) => {
return startServer(config);
@@ -508,6 +581,32 @@ export async function setupIPC(): Promise {
});
}
+/** 校验 MEMORY.md 内容格式 */
+function validateMemoryContent(content: string): boolean {
+ if (!content || !content.trim()) return false;
+ const lines = content.split('\n');
+ const firstLine = lines[0]?.trim();
+ if (firstLine !== '# METONA MEMORY') return false;
+
+ // 检查是否有 ## 条目头,且格式正确
+ let entryCount = 0;
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith('>')) continue;
+ const match = trimmed.match(/^##\s+(fact|preference|rule)\s*\|\s*id:\s*(mem_\d{8}_\d{3})\s*\|\s*importance:\s*(\d{1,2})\s*\|\s*tags:\s*(.+)$/i);
+ if (match) {
+ entryCount++;
+ const importance = parseInt(match[3], 10);
+ if (importance < 1 || importance > 10) return false;
+ const tagsStr = match[4]?.trim();
+ if (!tagsStr) return false;
+ }
+ }
+
+ // 允许空文件(只有头部没有条目),也允许有条目的文件
+ return true;
+}
+
// ── 视频帧提取 (ffmpeg) ──
interface ExtractedFrame {
diff --git a/src/main/menu.ts b/src/main/menu.ts
index efcc46a..5c889b2 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.12.11',
+ message: 'Metona Ollama Desktop v0.13.0',
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 0211920..c393d3a 100644
--- a/src/main/preload.ts
+++ b/src/main/preload.ts
@@ -63,13 +63,6 @@ contextBridge.exposeInMainWorld('metonaDesktop', {
clearAllSessions: () => ipcRenderer.invoke('db:clearAllSessions'),
saveMessage: (msg: unknown) => ipcRenderer.invoke('db:saveMessage', msg),
getMessages: (sessionId: string) => ipcRenderer.invoke('db:getMessages', sessionId),
- saveMemory: (entry: unknown) => ipcRenderer.invoke('db:saveMemory', entry),
- getMemory: (id: string) => ipcRenderer.invoke('db:getMemory', id),
- getAllMemories: () => ipcRenderer.invoke('db:getAllMemories'),
- getMemoriesByType: (type: string) => ipcRenderer.invoke('db:getMemoriesByType', type),
- deleteMemory: (id: string) => ipcRenderer.invoke('db:deleteMemory', id),
- clearAllMemories: () => ipcRenderer.invoke('db:clearAllMemories'),
- searchMemories: (query: string, limit?: number) => ipcRenderer.invoke('db:searchMemories', query, limit),
saveSetting: (key: string, value: unknown) => ipcRenderer.invoke('db:saveSetting', key, value),
getSetting: (key: string, defaultValue?: unknown) => ipcRenderer.invoke('db:getSetting', key, defaultValue),
saveToolCall: (tc: unknown) => ipcRenderer.invoke('db:saveToolCall', tc),
@@ -129,5 +122,10 @@ contextBridge.exposeInMainWorld('metonaDesktop', {
removeProgressListener: () => {
ipcRenderer.removeAllListeners('video:progress');
}
+ },
+ memoryAccess: {
+ read: () => ipcRenderer.invoke('memory:read'),
+ write: (content: string) => ipcRenderer.invoke('memory:write', content),
+ init: () => ipcRenderer.invoke('memory:init'),
}
});
diff --git a/src/main/tool-security.ts b/src/main/tool-security.ts
index 0afaf8c..b0d8793 100644
--- a/src/main/tool-security.ts
+++ b/src/main/tool-security.ts
@@ -71,6 +71,9 @@ export interface CheckResult {
/** 路径安全豁免列表:这些路径即使匹配 BLOCKED_DIRS 也放行(如 app 自己的工作空间) */
let blocklistExemptions: string[] = [];
+/** 工作空间下受保护的文件名:任何工具都禁止直接读写,只能通过专用通道访问 */
+const BLOCKED_FILES = new Set(['MEMORY.md']);
+
/** 注册路径为安全豁免(不受 BLOCKED_DIRS 限制)。主要用于注册工作空间目录 */
export function addBlocklistExemptions(dirs: string[]): void {
for (const d of dirs) {
@@ -91,6 +94,24 @@ function isBlocklistExempt(resolved: string): boolean {
return false;
}
+/** 检查文件是否在工作空间下且属于受保护文件(MEMORY.md 等) */
+export function isBlockedFile(targetPath: string): boolean {
+ try {
+ const resolved = path.resolve(targetPath);
+ const basename = path.basename(resolved);
+ if (!BLOCKED_FILES.has(basename)) return false;
+ // 只保护工作空间下的 MEMORY.md(其他目录下的同名文件不保护)
+ for (const exempt of blocklistExemptions) {
+ if (resolved === path.join(exempt, basename) || resolved.startsWith(exempt + path.sep)) {
+ return true;
+ }
+ }
+ return false;
+ } catch {
+ return false;
+ }
+}
+
export function checkPathAllowed(targetPath: string, operation: 'read' | 'write'): CheckResult {
const resolved = path.resolve(targetPath);
@@ -98,6 +119,12 @@ export function checkPathAllowed(targetPath: string, operation: 'read' | 'write'
return { ok: false, reason: '路径遍历深度过大' };
}
+ // ── 文件级保护:MEMORY.md 等受保护文件禁止所有工具直接访问 ──
+ if (isBlockedFile(resolved)) {
+ const basename = path.basename(resolved);
+ return { ok: false, reason: `禁止直接访问 ${basename}。此文件只能通过 memory 工具操作,其他任何工具都无权读写。` };
+ }
+
for (const blocked of BLOCKED_DIRS) {
if (resolved === blocked || resolved.startsWith(blocked + path.sep)) {
// 如果路径在安全豁免列表中(如 app 自己的工作空间),放行
diff --git a/src/renderer/components/chat-area.ts b/src/renderer/components/chat-area.ts
index 43bdd2c..2499236 100644
--- a/src/renderer/components/chat-area.ts
+++ b/src/renderer/components/chat-area.ts
@@ -254,7 +254,7 @@ function renderToolCallCard(tc: ToolCallRecord): string {
edit_file: '✂️', get_file_info: 'ℹ️', tree: '🌳', download_file: '⬇️',
diff_files: '🔀', replace_in_files: '🔄', read_multiple_files: '📚',
git: '🔖', compress: '🗜️', web_search: '🔍',
- memory_search: '🧠', memory_add: '💾', session_list: '📋', session_read: '📖', spawn_task: '🤖', plan_track: '📋',
+ memory: '🧠', session_list: '📋', session_read: '📖', spawn_task: '🤖', plan_track: '📋',
browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰',
browser_click: '👆', browser_type: '⌨️', browser_scroll: '↕️', browser_close: '❌'
};
@@ -265,7 +265,7 @@ function renderToolCallCard(tc: ToolCallRecord): string {
edit_file: '编辑文件', get_file_info: '文件信息', tree: '目录树', download_file: '下载文件',
diff_files: '文件对比', replace_in_files: '批量替换', read_multiple_files: '批量读取',
git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索',
- memory_search: '搜索记忆', memory_add: '添加记忆', session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派', plan_track: '计划追踪',
+ memory: '记忆管理', session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派', plan_track: '计划追踪',
browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容',
browser_click: '点击元素', browser_type: '输入文本', browser_scroll: '滚动页面', browser_close: '关闭浏览器'
};
diff --git a/src/renderer/components/input-area.ts b/src/renderer/components/input-area.ts
index 4600f21..7dd2e67 100644
--- a/src/renderer/components/input-area.ts
+++ b/src/renderer/components/input-area.ts
@@ -17,7 +17,6 @@ import { ChatDB } from '../db/chat-db.js';
import { OllamaAPI } from '../api/ollama.js';
import { runAgentLoop } from '../services/agent-engine.js';
import { estimateTokens } from '../services/context-manager.js';
-import { searchMemories, buildMemoryContext, markMemoryUsed, extractMemoriesFromConversation, isMemoryEnabled } from '../services/memory-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, FileContent, ChatFile, ToolCallRecord, AgentMode } from '../types.js';
diff --git a/src/renderer/components/memory-modal.ts b/src/renderer/components/memory-modal.ts
index 55ca3a7..4bf8098 100644
--- a/src/renderer/components/memory-modal.ts
+++ b/src/renderer/components/memory-modal.ts
@@ -1,24 +1,18 @@
/**
- * MemoryModal - Agent 记忆管理大模态框
- * 左右分栏布局:左侧分类筛选+统计,右侧搜索+列表+操作
+ * MemoryModal - Agent 记忆管理(简化版)
+ * 基于工作空间 MEMORY.md 文件
*/
-import {
- getMemoryCache, searchMemories, addMemory, updateMemory, deleteMemory,
- clearAllMemories, isMemoryEnabled, setMemoryEnabled,
- getTypeIcon, getTypeName, isVectorMemoryEnabled, getEmbeddingModel
-} from '../services/memory-manager.js';
+import { loadAllEntries, addEntry, removeEntry, type MemoryEntry, type MemoryType } from '../services/memory-service.js';
import { showToast } from './toast.js';
import { showPrompt, showConfirm } from './prompt-modal.js';
-import { debounce, escapeHtml, formatTime } from '../utils/utils.js';
-import type { MemoryEntry, MemoryType } from '../types.js';
+import { escapeHtml, formatTime } from '../utils/utils.js';
let modalEl: HTMLElement;
// ── 初始化 ──
export function initMemoryModal(): void {
- // 创建模态框 DOM
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.id = 'memoryModal';
@@ -26,12 +20,9 @@ export function initMemoryModal(): void {
overlay.innerHTML = `
-
`;
@@ -105,298 +40,96 @@ export function initMemoryModal(): void {
document.querySelector('#app')!.appendChild(overlay);
modalEl = overlay;
- // 绑定事件
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeMemoryModal();
});
overlay.querySelector('#btnCloseMemoryModal')!.addEventListener('click', closeMemoryModal);
-
- // 自动记忆开关
- const toggle = overlay.querySelector('#memoryModalToggle') as HTMLInputElement;
- toggle.checked = isMemoryEnabled();
- toggle.addEventListener('change', () => {
- setMemoryEnabled(toggle.checked);
- showToast(toggle.checked ? '自动记忆已开启' : '自动记忆已关闭', 'info');
- });
-
- // 搜索
- const searchInput = overlay.querySelector('#memorySearchLg') as HTMLInputElement;
- searchInput.addEventListener('input', debounce(() => renderList(), 300));
-
- // 分类筛选
- overlay.querySelectorAll('.memory-category-item').forEach(el => {
- el.addEventListener('click', () => {
- overlay.querySelectorAll('.memory-category-item').forEach(c => c.classList.remove('active'));
- el.classList.add('active');
- renderList();
- });
- });
-
- // 添加记忆
overlay.querySelector('#btnAddMemoryLg')!.addEventListener('click', openAddDialog);
- // 导入 Markdown 记忆文档
- overlay.querySelector('#btnImportMemoryMd')!.addEventListener('click', importMemoryFromMd);
-
- // 清空
- overlay.querySelector('#btnClearMemoryLg')!.addEventListener('click', async () => {
- if (await showConfirm('确定清空所有记忆?此操作不可恢复!', '清空记忆')) {
- await clearAllMemories();
- renderList();
- updateStats();
- showToast('已清空所有记忆', 'success');
+ // 列表点击:删除按钮
+ overlay.querySelector('#memoryListSimple')!.addEventListener('click', async (e) => {
+ const target = e.target as HTMLElement;
+ if (target.classList.contains('memory-item-delete')) {
+ const id = target.dataset.id!;
+ const entries = await loadAllEntries();
+ const entry = entries.find(e => e.id === id);
+ if (entry && await showConfirm(`确定删除这条记忆?\n\n${entry.content.slice(0, 100)}`, '删除记忆')) {
+ await removeEntry(entry.content.slice(0, 50));
+ renderList();
+ showToast('记忆已删除', 'info', 1500);
+ }
}
});
// header 按钮
document.querySelector('#btnMemory')?.addEventListener('click', openMemoryModal);
-
- // 全局状态监听
- if (typeof (window as unknown as { __memoryStateListener?: boolean }).__memoryStateListener === 'undefined') {
- (window as unknown as { __memoryStateListener?: boolean }).__memoryStateListener = true;
- // 简单轮询:在模态框打开时监听变化
- }
}
// ── 打开/关闭 ──
-export function openMemoryModal(): void {
+export async function openMemoryModal(): Promise
{
modalEl.style.display = '';
- (modalEl.querySelector('#memoryModalToggle') as HTMLInputElement).checked = isMemoryEnabled();
- updateVectorBadge();
- updateStats();
- renderList();
- (modalEl.querySelector('#memorySearchLg') as HTMLInputElement).focus();
+ await renderList();
}
export function closeMemoryModal(): void {
modalEl.style.display = 'none';
}
-// ── 向量记忆状态 ──
-
-function updateVectorBadge(): void {
- const badge = modalEl.querySelector('#memoryVectorBadge')!;
- const importBtn = modalEl.querySelector('#btnImportMemoryMd') as HTMLButtonElement;
- if (isVectorMemoryEnabled()) {
- badge.textContent = `✅ 向量搜索 (${getEmbeddingModel()})`;
- badge.className = 'memory-vector-badge enabled';
- importBtn.disabled = false;
- importBtn.title = '导入 Markdown 文档(自动向量化)';
- } else {
- badge.textContent = '关键词模式';
- badge.className = 'memory-vector-badge disabled';
- importBtn.disabled = true;
- importBtn.title = '需先在设置中配置嵌入模型';
- }
-}
-
-// ── 统计 ──
-
-function updateStats(): void {
- const entries = getMemoryCache();
- const counts: Record = { total: entries.length, fact: 0, preference: 0, rule: 0 };
- for (const e of entries) {
- if (e.type in counts) counts[e.type]++;
- }
- const el = (id: string) => modalEl.querySelector(`#${id}`)!;
- el('memStatTotal').textContent = String(counts.total);
- el('memStatFact').textContent = String(counts.fact);
- el('memStatPref').textContent = String(counts.preference);
- el('memStatRule').textContent = String(counts.rule);
- el('catCountAll').textContent = String(counts.total);
- el('catCountFact').textContent = String(counts.fact);
- el('catCountPref').textContent = String(counts.preference);
- el('catCountRule').textContent = String(counts.rule);
-}
-
// ── 列表渲染 ──
-function getActiveType(): string {
- const active = modalEl.querySelector('.memory-category-item.active') as HTMLElement;
- return active?.dataset.type || '';
-}
-
-function renderList(): void {
- const listEl = modalEl.querySelector('#memoryListLg')!;
- const query = (modalEl.querySelector('#memorySearchLg') as HTMLInputElement).value.trim();
- const typeFilter = getActiveType();
+async function renderList(): Promise {
+ const listEl = modalEl.querySelector('#memoryListSimple') as HTMLElement;
+ const footerEl = modalEl.querySelector('#memoryFooterSimple') as HTMLElement;
let entries: MemoryEntry[];
- if (query) {
- entries = searchMemories(query, 50);
- } else {
- entries = getMemoryCache();
+ try {
+ entries = await loadAllEntries();
+ } catch (err) {
+ listEl.innerHTML = `加载失败: ${escapeHtml((err as Error).message)}
`;
+ return;
}
- if (typeFilter) {
- entries = entries.filter(e => e.type === typeFilter);
- }
-
- entries.sort((a, b) => {
- if (a.importance !== b.importance) return b.importance - a.importance;
- return b.updatedAt - a.updatedAt;
- });
-
if (entries.length === 0) {
- listEl.innerHTML = `${query ? '未找到匹配的记忆' : '暂无记忆,AI 会自动在对话中学习'}
`;
+ listEl.innerHTML = `
+
🧠
+
暂无记忆
+
AI 在对话中可通过 memory 工具自动管理记忆
工作空间 MEMORY.md 文件存储
+
`;
+ footerEl.textContent = '在对话中让 AI "记住xxx"即可自动添加记忆';
return;
}
- listEl.innerHTML = entries.map(entry => {
- const icon = getTypeIcon(entry.type);
- const typeName = getTypeName(entry.type);
- const importanceStars = '★'.repeat(Math.min(entry.importance, 10)) + '☆'.repeat(Math.max(0, 10 - entry.importance));
- return `
-
-
AI 对话中自动调用工具,结果在此展示
-
共 44 个内置工具 · MCP 动态扩展
+
共 40 个内置工具 · MCP 动态扩展
`;
return;
@@ -983,7 +983,7 @@ function getToolDisplayName(name: string): string {
edit_file: '编辑文件', get_file_info: '文件信息', tree: '目录树', download_file: '下载文件',
diff_files: '文件对比', replace_in_files: '批量替换', read_multiple_files: '批量读取',
git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索',
- memory_search: '搜索记忆', memory_add: '添加记忆', memory_replace: '替换记忆', memory_remove: '删除记忆',
+ memory: '记忆管理',
session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派', plan_track: '计划追踪',
browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容',
@@ -1000,7 +1000,7 @@ function getToolIcon(name: string): string {
edit_file: '✂️', get_file_info: 'ℹ️', tree: '🌳', download_file: '⬇️',
diff_files: '🔀', replace_in_files: '🔄', read_multiple_files: '📚',
git: '🔖', compress: '🗜️', web_search: '🔍',
- memory_search: '🧠', memory_add: '💾', memory_replace: '✏️', memory_remove: '🗑️',
+ memory: '🧠',
session_list: '📋', session_read: '📖', spawn_task: '🤖', plan_track: '📋',
browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰',
diff --git a/src/renderer/db/chat-db.ts b/src/renderer/db/chat-db.ts
index e61d880..2d2b6c4 100644
--- a/src/renderer/db/chat-db.ts
+++ b/src/renderer/db/chat-db.ts
@@ -3,7 +3,7 @@
* v4.0: 桌面端走 IPC → SQLite,Web 端仍用 IndexedDB
*/
-import type { ChatSession, MemoryEntry } from '../types.js';
+import type { ChatSession } from '../types.js';
/** 检查是否在桌面环境 */
function isDesktop(): boolean {
@@ -217,89 +217,8 @@ export class ChatDB {
return this._idbGetSetting(key, defaultValue);
}
- // ── Memories ──
-
- async saveMemory(entry: MemoryEntry): Promise {
- if (isDesktop()) {
- const row = {
- id: entry.id,
- type: entry.type,
- content: entry.content,
- importance: entry.importance,
- tags: entry.tags?.length ? JSON.stringify(entry.tags) : null,
- source: entry.source || null,
- session_id: entry.sessionId || null,
- use_count: entry.useCount,
- embedding: entry.embedding ? JSON.stringify(entry.embedding) : null,
- created_at: entry.createdAt,
- updated_at: entry.updatedAt,
- last_used_at: entry.lastUsedAt
- };
- await dbBridge().saveMemory(row);
- return entry.id;
- }
- return this._idbSaveMemory(entry);
- }
-
- async getMemory(id: string): Promise {
- if (isDesktop()) {
- const row = await dbBridge().getMemory(id);
- return row ? this._rowToMemory(row) : null;
- }
- return this._idbGetMemory(id);
- }
-
- async getAllMemories(): Promise {
- if (isDesktop()) {
- const rows = await dbBridge().getAllMemories();
- return rows.map((r: any) => this._rowToMemory(r));
- }
- return this._idbGetAllMemories();
- }
-
- async getMemoriesByType(type: string): Promise {
- if (isDesktop()) {
- const rows = await dbBridge().getMemoriesByType(type);
- return rows.map((r: any) => this._rowToMemory(r));
- }
- return this._idbGetMemoriesByType(type);
- }
-
- async deleteMemory(id: string): Promise {
- if (isDesktop()) {
- await dbBridge().deleteMemory(id);
- return;
- }
- return this._idbDeleteMemory(id);
- }
-
- async clearAllMemories(): Promise {
- if (isDesktop()) {
- await dbBridge().clearAllMemories();
- return;
- }
- return this._idbClearAllMemories();
- }
-
// ── Helpers ──
- private _rowToMemory(row: any): MemoryEntry {
- return {
- id: row.id,
- type: row.type,
- content: row.content,
- importance: row.importance,
- tags: row.tags ? JSON.parse(row.tags) : [],
- source: row.source || undefined,
- sessionId: row.session_id || undefined,
- useCount: row.use_count,
- embedding: row.embedding ? JSON.parse(row.embedding) : undefined,
- createdAt: row.created_at,
- updatedAt: row.updated_at,
- lastUsedAt: row.last_used_at
- };
- }
-
// ═══════════════════════════════════════════
// IndexedDB fallback methods (Web 端兼容)
// ═══════════════════════════════════════════
@@ -393,61 +312,6 @@ export class ChatDB {
});
}
- private async _idbSaveMemory(entry: MemoryEntry): Promise {
- return new Promise((resolve, reject) => {
- const store = this._idbTx('memories', 'readwrite');
- const req = store.put(entry);
- req.onsuccess = () => resolve(entry.id);
- req.onerror = () => reject(req.error);
- });
- }
-
- private async _idbGetMemory(id: string): Promise {
- return new Promise((resolve, reject) => {
- const store = this._idbTx('memories');
- const req = store.get(id);
- req.onsuccess = () => resolve(req.result || null);
- req.onerror = () => reject(req.error);
- });
- }
-
- private async _idbGetAllMemories(): Promise {
- return new Promise((resolve, reject) => {
- const store = this._idbTx('memories');
- const req = store.getAll();
- req.onsuccess = () => resolve(req.result || []);
- req.onerror = () => reject(req.error);
- });
- }
-
- private async _idbGetMemoriesByType(type: string): Promise {
- return new Promise((resolve, reject) => {
- const store = this._idbTx('memories');
- const index = store.index('type');
- const req = index.getAll(type);
- req.onsuccess = () => resolve(req.result || []);
- req.onerror = () => reject(req.error);
- });
- }
-
- private async _idbDeleteMemory(id: string): Promise {
- return new Promise((resolve, reject) => {
- const store = this._idbTx('memories', 'readwrite');
- const req = store.delete(id);
- req.onsuccess = () => resolve();
- req.onerror = () => reject(req.error);
- });
- }
-
- private async _idbClearAllMemories(): Promise {
- return new Promise((resolve, reject) => {
- const store = this._idbTx('memories', 'readwrite');
- const req = store.clear();
- req.onsuccess = () => resolve();
- req.onerror = () => reject(req.error);
- });
- }
-
/** 获取原始 IndexedDB 实例(用于数据迁移) */
getRawIDB(): IDBDatabase | null {
return this.idb;
diff --git a/src/renderer/index.html b/src/renderer/index.html
index b72379c..cb6e3fe 100644
--- a/src/renderer/index.html
+++ b/src/renderer/index.html
@@ -28,7 +28,7 @@
-
-
启用后,AI 会自动从对话中提取关键信息(事实、偏好、规则)并跨会话记住。发送消息时自动检索相关记忆注入上下文。
+
🧠 Agent 记忆系统
+
记忆存储在工作空间 MEMORY.md 文件中。新对话时自动检索注入上下文,对话结束时 AI 自动提取保存(多层质量过滤,宁缺毋滥)。受路径保护,仅 memory 工具可读写。
-
-
🧠 向量记忆引擎
-
选择嵌入模型以启用向量语义记忆。未选择时仅使用关键词匹配。
-
-
-
🤖 子代理默认模型
子代理(spawn_task)使用的默认模型。留空则使用当前选中的模型。可为简单任务选择更快的模型。
@@ -371,18 +357,23 @@
-
🤖 子代理参数
-
最大轮次(留空=10轮)和超时(留空=300s,填0=禁用)
-
- 最大轮次
-
- 超时(秒)
-
-
-
-
看门狗(分)
-
-
0=禁用,默认30分钟
+
🤖 子代理 & 看门狗参数
+
+
+
🔄 子代理最大轮次
+
+
spawn_task 子代理的最大工具调用轮次
+
+
+
⏱️ 子代理超时(秒)
+
+
子代理单次任务最大执行时间,0=禁用
+
+
+
🐕 看门狗超时(分钟)
+
+
Agent Loop 全局超时保护,超过后强制终止。0=禁用,默认30分钟
+
@@ -448,8 +439,8 @@
🚀 快速开始
- 确保 Ollama 已启动(默认
http://127.0.0.1:11434,可在设置中修改) - 顶部模型栏选择一个模型,右侧会显示模型能力徽章(🧠 Think / 👁️ Vision / 🔧 Tools)
- 输入消息,按 Enter 发送,Shift+Enter 换行
💬 聊天功能
- 流式回复 — 实时打字效果,随时点 ■ 停止
- Think 推理 — 下方 Think 按钮切换,让模型展示深度思考过程(需模型支持,如 Qwen3)
- 上下文长度自动检测 — 切换模型时自动从模型元数据获取实际支持的上下文长度(如 Qwen3.6 27B = 262,144 tokens),无需手动配置
- 多模态 — 上传图片或视频(≤10MB),模型需支持 Vision。图片自动压缩,视频 1fps 提取帧序列带时序标注
- 文件分析 — 支持 50+ 种文本/代码格式,单文件 ≤500KB,自动剥离注释并按上下文预算智能截断
- AI 回复顶部 — 每条 AI 回复上方展示 📋 系统提示词折叠卡片,可点击查看实际发送给模型的完整上下文
-
🔧 Tool Calling(始终开启)
- 所有消息均通过 Agent Loop 自主调用本地工具,像一个本地 Agent,无普通聊天模式
- 42+1 个工具,分为 9 类:
- 文件系统(16 个):read_file / write_file / list_directory / search_files / create_directory / delete_file / move_file / copy_file / edit_file / get_file_info / tree / download_file / diff_files / replace_in_files / read_multiple_files / compress
- 命令执行(1 个):run_command(实时流式输出,支持自动/需确认/禁用三种模式,可配置超时)
- 联网搜索(2 个):web_search(支持 SearXNG 元搜索引擎 JSON API / 四引擎 HTML 解析,双模式可切换)/ web_fetch(反爬headers+UA自动切换+自动回退浏览器渲染)
- Git(1 个):git(17 个子操作,push/pull/clone 内置60-120s超时保护)
- 浏览器控制(9 个):browser_open / browser_screenshot / browser_evaluate / browser_extract / browser_click / browser_type / browser_scroll / browser_wait / browser_close
- 记忆 & 会话(6 个):memory_search / memory_add / memory_replace / memory_remove / session_list / session_read
- 子代理(1 个):spawn_task
- 系统工具(6 个):datetime / calculator / random / uuid / json_format / hash
- read_file 支持 binary模式读取/base64解码/字节分页/2000行默认+截断hint自动续读
- write_file 支持 base64写二进制文件/mode(overwrite+append)合并append_file功能/10MB
- edit_file 支持 use_regex 正则替换
- search_files 支持正则表达式搜索(use_regex=true)
- browser_screenshot 支持全页面截图+元素截图
- browser_extract 支持CSS选择器提取特定区域
- new browser_wait 等待元素出现或定时等待
- 仅
run_command 支持三模式切换:自动/需确认/禁用 - 危险命令(
rm -rf、mkfs、反弹 shell 等)和系统路径(/etc、~/.ssh 等)被自动拦截 - 工具调用以可视化卡片展示状态(pending → running → success/error),在工作空间🔧工具页签中显示
- 默认最大 85 轮工具调用循环,上下文使用率>80%时自动缩减到3轮
- 独立工具自动并行执行(16个只读工具加入并行白名单),有依赖关系的工具串行执行
-
🧠 Agent 记忆系统
- 设置中开启后,AI 从对话中自动提取关键信息(事实/偏好/规则),跨会话持续积累
- 增量提取:长对话中每 20 轮自动触发轻量级记忆提取,不等到对话结束
- 对话结束时自动触发完整记忆提取
- 新对话时自动检索相关记忆注入上下文,让 AI "记住"你
- 点击顶部 🧠 按钮打开记忆面板:搜索、筛选、编辑、删除
- 记忆容量上限 500 条,超限时自动清理低价值条目(规则类型受保护)
- 向量语义搜索:在设置中选择嵌入模型后,支持语义级别记忆检索(更精准)
- 未选择嵌入模型时,使用关键词匹配检索记忆
- AI 可通过 memory 工具主动管理记忆
- 记忆写入前自动安全扫描(prompt injection / 敏感信息 / 不可见字符检测)
- 记忆存储在本地 SQLite,不会上传到任何服务器
+
🔧 Tool Calling(始终开启)
- 所有消息均通过 Agent Loop 自主调用本地工具,像一个本地 Agent,无普通聊天模式
- 40+1 个工具,分为 9 类:
- 文件系统(16 个):read_file / write_file / list_directory / search_files / create_directory / delete_file / move_file / copy_file / edit_file / get_file_info / tree / download_file / diff_files / replace_in_files / read_multiple_files / compress
- 命令执行(1 个):run_command(实时流式输出,支持自动/需确认/禁用三种模式,可配置超时)
- 联网搜索(2 个):web_search(支持 SearXNG 元搜索引擎 JSON API / 四引擎 HTML 解析,双模式可切换)/ web_fetch(反爬headers+UA自动切换+自动回退浏览器渲染)
- Git(1 个):git(17 个子操作,push/pull/clone 内置60-120s超时保护)
- 浏览器控制(9 个):browser_open / browser_screenshot / browser_evaluate / browser_extract / browser_click / browser_type / browser_scroll / browser_wait / browser_close
- 记忆 & 会话(5 个):memory(统一记忆管理,5 个 action)/ session_list / session_read / spawn_task / plan_track
- 系统工具(6 个):datetime / calculator / random / uuid / json_format / hash
- read_file 支持 binary模式读取/base64解码/字节分页/2000行默认+截断hint自动续读
- write_file 支持 base64写二进制文件/mode(overwrite+append)合并append_file功能/10MB
- edit_file 支持 use_regex 正则替换
- search_files 支持正则表达式搜索(use_regex=true)
- browser_screenshot 支持全页面截图+元素截图
- browser_extract 支持CSS选择器提取特定区域
- new browser_wait 等待元素出现或定时等待
- 仅
run_command 支持三模式切换:自动/需确认/禁用 - 危险命令(
rm -rf、mkfs、反弹 shell 等)和系统路径(/etc、~/.ssh 等)被自动拦截 - 工具调用以可视化卡片展示状态(pending → running → success/error),在工作空间🔧工具页签中显示
- 默认最大 85 轮工具调用循环,上下文使用率>80%时自动缩减到3轮
- 独立工具自动并行执行(16个只读工具加入并行白名单),有依赖关系的工具串行执行
+
🧠 Agent 记忆系统
- 记忆存储在工作空间 MEMORY.md 文件,受严格路径保护,仅
memory 工具可读写 - 新对话时自动检索相关记忆注入 AI 上下文,让 AI "记住"你
- 对话结束时 AI 自动提取有价值的用户信息保存(多层质量过滤,宁缺毋滥)
- memory 工具(5 个 action):search(关键词搜索)/ add(添加)/ replace(替换)/ remove(删除)/ read_all(读取全部)
- 点击顶部 🧠 按钮打开记忆面板:查看、添加、删除记忆条目
- 记忆容量上限 500 条,超限时自动清理低价值条目(规则类型受保护)
- 写入前自动安全扫描(prompt injection / 敏感信息 / 不可见字符检测)
- 应用启动时自动校验 MEMORY.md 格式,格式错误自动备份重建
📋 Plan Mode(计划模式)
- 点击输入框上方 📋 按钮开启 Plan Mode(开关式)
- 开启后,AI 先生成执行计划(Markdown 渲染弹窗),用户批准后才开始执行
- 计划批准后自动初始化追踪器,每个步骤完成后调用
plan_track 工具标记进度 - 系统提示词自动注入当前进度状态,AI 始终知道还剩多少步未完成
- 多步骤任务防遗忘:自动检测用户请求中的动作动词,对比已完成步骤,注入提醒
- 关闭 Plan Mode 后恢复正常 Agent Loop 模式
- 仅 Plan 模式 下 plan_track 工具可见,避免污染普通模式的工具列表
🛡️ 抗幻觉 & 稳定性
- 5 层防御体系:系统提示词加固 → 中途幻觉检测(16 条规则覆盖全部工具类别)→ 进度锚点 → 完成闸门(6 项检查)→ 钩子校验
- 完成闸门:对话结束前自动审查 AI 回复,检测 4 类关键问题(工具幻觉/注入/文件操作/遗漏),阻断有问题的回复
- 16 条幻觉检测规则:覆盖文件编辑、浏览器操作、Git、记忆、压缩等所有工具类别,防止 AI 声称执行了未调用的工具
- 看门狗超时:设置面板可配置全局超时(默认 30 分钟),AI 卡死或无限循环时自动中止
- 中止保护:所有状态处理器 + 重试循环均检查中止信号,点击 ■ 按钮立即生效
- 上下文硬上限:消息数超过阈值时强制触发压缩,防止 OOM
🤖 Agent Loop 增强
- 🎬 视频上传:支持上传 .mp4/.avi/.mov/.mkv/.webm 等视频(≤10MB),自动 1fps 提取帧序列(带时间戳),多模态模型原生理解视频时序关系
- 8 状态机:INIT→THINKING→PARSING→EXECUTING→OBSERVING→REFLECTING→COMPRESSING→TERMINATED,每个状态独立的中止检查和处理
- 智能上下文压缩:滑动窗口 + LLM结构化JSON摘要,超过50%上下文窗口自动触发
- 流式调用超时保护:可配置超时(默认300s),Ollama 卡死不再永久阻塞
- HTTP/MCP 超时可配:设置面板可分别调整 HTTP(默认30s)和 MCP(默认60s)超时
- Final Answer 智能检测:多模式匹配(Final Answer/最终答案/最终回答/总结),避免漏检或误触
- Token 感知迭代预算:上下文使用率>80%时自动缩减剩余轮次到3轮
- 工具缓存 TTL:搜索5分钟/网页10分钟/文件30分钟,过期自动刷新
- 自动子任务拆解:检测"同时/分别/以及"等并行关键词,自动 spawn 子代理并行处理
- 旧工具结果自动截断:超过10轮的工具结果自动截断到500字符,控制上下文膨胀
@@ -468,7 +459,7 @@