v0.13.0: 记忆系统重构 — MEMORY.md 文件化 + 路径保护 + 自动提取优化

核心变更:
- 删除 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
This commit is contained in:
紫影233
2026-06-24 14:49:09 +08:00
parent dcaf5982fc
commit 0903d740da
26 changed files with 1207 additions and 2364 deletions
+17 -25
View File
@@ -14,7 +14,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/version-v0.12.11-E8734A?style=flat-square" alt="version"> <img src="https://img.shields.io/badge/version-v0.13.0-E8734A?style=flat-square" alt="version">
<img src="https://img.shields.io/badge/electron-33+-47848F?style=flat-square&logo=electron" alt="electron"> <img src="https://img.shields.io/badge/electron-33+-47848F?style=flat-square&logo=electron" alt="electron">
<img src="https://img.shields.io/badge/typescript-5.7+-3178C6?style=flat-square&logo=typescript" alt="typescript"> <img src="https://img.shields.io/badge/typescript-5.7+-3178C6?style=flat-square&logo=typescript" alt="typescript">
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="license"> <img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="license">
@@ -37,8 +37,8 @@
| 🤖 | **ReAct Agent Loop** | 始终开启的唯一对话模式。8 状态机(INIT→THINKING→PARSING→EXECUTING→OBSERVING→REFLECTING→COMPRESSING→TERMINATED),最大 85 轮(可配置),自动重试 2 次,工具去重,并行/链式执行,看门狗超时保护 | | 🤖 | **ReAct Agent Loop** | 始终开启的唯一对话模式。8 状态机(INIT→THINKING→PARSING→EXECUTING→OBSERVING→REFLECTING→COMPRESSING→TERMINATED),最大 85 轮(可配置),自动重试 2 次,工具去重,并行/链式执行,看门狗超时保护 |
| 🛡️ | **5 层抗幻觉系统** | 提示词加固 → 任务感知 → 中途检测(16 条规则覆盖全部工具类别)→ 进度锚点 → 完成闸门(6 项检查,幻觉/注入/文件/工具 4 类关键阻断) | | 🛡️ | **5 层抗幻觉系统** | 提示词加固 → 任务感知 → 中途检测(16 条规则覆盖全部工具类别)→ 进度锚点 → 完成闸门(6 项检查,幻觉/注入/文件/工具 4 类关键阻断) |
| 📋 | **Plan Mode** | 开关切换,AI 首先生成执行计划(Markdown 渲染确认弹窗),批准后按步骤追踪执行,plan_track 工具标记完成状态 | | 📋 | **Plan Mode** | 开关切换,AI 首先生成执行计划(Markdown 渲染确认弹窗),批准后按步骤追踪执行,plan_track 工具标记完成状态 |
| 🔧 | **42 个内置工具** | 文件系统(16个) · 命令执行 · 联网搜索 · 浏览器控制(9个) · Git · 记忆 · 会话 · 子代理 · 系统工具 · Plan Mode 追踪 | | 🔧 | **40 个内置工具** | 文件系统(16个) · 命令执行 · 联网搜索 · 浏览器控制(9个) · Git · 记忆 · 会话 · 子代理 · 系统工具 · Plan Mode 追踪 |
| 🧠 | **智能记忆系统** | 三类记忆(fact / preference / rule),FTS5 全文搜索 + 向量语义搜索,写入前安全扫描,容量 500 条,90 天衰减 | | 🧠 | **智能记忆系统** | 三类记忆(fact / preference / rule),存储于工作空间 MEMORY.md 文件,受路径保护仅 memory 工具可访问,写入前安全扫描,容量 500 条,对话结束自动提取,严格格式校验 |
| 📋 | **自定义文件** | SOUL.md(人格,不可压缩)+ AGENT.md(行为准则)+ USER.md(用户画像),工作空间优先,内置默认 fallback | | 📋 | **自定义文件** | SOUL.md(人格,不可压缩)+ AGENT.md(行为准则)+ USER.md(用户画像),工作空间优先,内置默认 fallback |
| 🌐 | **MCP 协议扩展** | JSON-RPC 2.0 over stdio,动态工具发现,Shadowing 防护 | | 🌐 | **MCP 协议扩展** | JSON-RPC 2.0 over stdio,动态工具发现,Shadowing 防护 |
| 🔍 | **联网搜索(双模式)** | SearXNG 元搜索引擎 JSON API70+引擎聚合)/ 四引擎 HTML 解析(Bing+百度+搜狗+360),双模式可切换;web_fetch 支持反爬+UA切换+浏览器回退 | | 🔍 | **联网搜索(双模式)** | SearXNG 元搜索引擎 JSON API70+引擎聚合)/ 四引擎 HTML 解析(Bing+百度+搜狗+360),双模式可切换;web_fetch 支持反爬+UA切换+浏览器回退 |
@@ -129,14 +129,11 @@
</details> </details>
<details> <details>
<summary><strong>🧠 记忆管理(4 个)</strong></summary> <summary><strong>🧠 记忆管理(1 个)</strong></summary>
| 工具 | 功能 | | 工具 | 功能 |
|------|------| |------|------|
| `memory_search` | 搜索记忆(FTS5 + 向量语义) | | `memory` | 统一记忆管理(5 个 actionsearch 搜索 / add 添加 / replace 替换 / remove 删除 / read_all 读取全部)。工作空间 MEMORY.md 受路径保护,仅此工具可访问 |
| `memory_add` | 添加记忆(自动安全扫描) |
| `memory_replace` | 修改记忆 |
| `memory_remove` | 删除记忆 |
</details> </details>
@@ -179,7 +176,7 @@
``` ```
用户消息 → 扫描工作空间 SOUL.md(不可压缩层)→ AGENT.md → USER.md 用户消息 → 扫描工作空间 SOUL.md(不可压缩层)→ AGENT.md → USER.md
记忆检索 (FTS5 + 向量语义搜索) → 上下文注入 记忆检索 (MEMORY.md 关键词搜索) → 上下文注入
Agent Engine (8 状态机 ReAct Loop, ≤85 轮, 看门狗 30min) 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 父子关系) | | `sessions` | 会话(parent_id 父子关系) |
| `messages` | 消息(外键级联删除) | | `messages` | 消息(外键级联删除) |
| `tool_calls` | 工具调用记录 | | `tool_calls` | 工具调用记录 |
| `memories` | Agent 记忆(FTS5 + 向量嵌入) |
| `settings` | 设置(JSON 序列化) | | `settings` | 设置(JSON 序列化) |
| `traces` | ReAct 执行轨迹 | | `traces` | ReAct 执行轨迹 |
@@ -234,7 +230,7 @@ SQLite (sql.js WASM)6 张表,WAL 模式 + FTS5 全文搜索:
| 🖥️ 前端渲染 | HTML 净化器(白名单标签 + URI 协议检查) | | 🖥️ 前端渲染 | HTML 净化器(白名单标签 + URI 协议检查) |
| 🔒 Electron | `contextIsolation: true` + IPC 白名单 + IPC fs 路径验证 | | 🔒 Electron | `contextIsolation: true` + IPC 白名单 + IPC fs 路径验证 |
| 🔐 数据加密 | AES-256-GCM | | 🔐 数据加密 | AES-256-GCM |
| 🧠 记忆安全 | 写入前 Prompt Injection / 敏感信息检测 | | 🧠 记忆安全 | 写入前 Prompt Injection / 敏感信息检测,多层级去重过滤 |
| 🌐 MCP 安全 | Shadowing 防护 + 双下划线分隔防歧义 | | 🌐 MCP 安全 | Shadowing 防护 + 双下划线分隔防歧义 |
| 🌐 网络安全 | `web_fetch` 流式体积限制(10MB+ 无 content-length 时防 OOM | | 🌐 网络安全 | `web_fetch` 流式体积限制(10MB+ 无 content-length 时防 OOM |
| ⚡ 进程安全 | Windows `taskkill` 强制终止 + 工作空间路径大小写不敏感校验 | | ⚡ 进程安全 | Windows `taskkill` 强制终止 + 工作空间路径大小写不敏感校验 |
@@ -256,7 +252,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
``` ```
产出:`release/Metona Ollama Setup v0.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 | | 🤖 | **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) | | 🛡️ | **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 | | 📋 | **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 | | 🔧 | **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), FTS5 + vector semantic search, pre-write security scan, 500 capacity, 90-day decay | | 🧠 | **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 | | 📋 | **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 | | 🌐 | **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 | | 🔍 | **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 安装包
</details> </details>
<details> <details>
<summary><strong>🧠 Memory Management (4)</strong></summary> <summary><strong>🧠 Memory Management (1)</strong></summary>
| Tool | Function | | Tool | Function |
|------|------| |------|------|
| `memory_search` | Search memories (FTS5 + vector semantic) | | `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_add` | Add memory (auto security scan) |
| `memory_replace` | Modify memory |
| `memory_remove` | Delete memory |
</details> </details>
@@ -430,7 +423,7 @@ npm run dist # 构建 Windows 安装包
``` ```
User message → workspace SOUL.md (never compressed) → AGENT.md → USER.md 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) Agent Engine (8-state ReAct Loop, ≤85 iter, watchdog 30min)
@@ -465,14 +458,13 @@ main.ts (Entry)
### 🗄️ Database ### 🗄️ Database
SQLite (sql.js WASM), 6 tables, WAL mode + FTS5 full-text search: SQLite (sql.js WASM), 5 tables, WAL mode:
| Table | Purpose | | Table | Purpose |
|---|---| |---|---|
| `sessions` | Sessions (parent_id hierarchy) | | `sessions` | Sessions (parent_id hierarchy) |
| `messages` | Messages (cascade delete) | | `messages` | Messages (cascade delete) |
| `tool_calls` | Tool call records | | `tool_calls` | Tool call records |
| `memories` | Agent memories (FTS5 + vector embedding) |
| `settings` | Settings (JSON serialization) | | `settings` | Settings (JSON serialization) |
| `traces` | ReAct execution traces | | `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) | | 🖥️ Frontend Rendering | HTML sanitizer (whitelist tags + URI protocol check) |
| 🔒 Electron | `contextIsolation: true` + IPC whitelist + IPC fs path validation | | 🔒 Electron | `contextIsolation: true` + IPC whitelist + IPC fs path validation |
| 🔐 Data Encryption | AES-256-GCM | | 🔐 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 | | 🌐 MCP Security | Shadowing protection + double-underscore delimiter disambiguation |
| 🌐 Network Security | `web_fetch` streaming size limit (10MB) + OOM prevention without content-length | | 🌐 Network Security | `web_fetch` streaming size limit (10MB) + OOM prevention without content-length |
| ⚡ Process Security | Windows `taskkill` forced termination + case-insensitive workspace path validation | | ⚡ 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 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 ## 🛠️ Common Commands
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "0.12.11", "version": "0.13.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "0.12.11", "version": "0.13.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ffmpeg-static": "^5.2.0", "ffmpeg-static": "^5.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "0.12.11", "version": "0.13.0",
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端", "description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
"main": "dist/main/main.js", "main": "dist/main/main.js",
"author": "thzxx", "author": "thzxx",
+1 -142
View File
@@ -73,7 +73,6 @@ function runTransaction(db: SQL.Database, fn: () => void): void {
let db: SQL.Database | null = null; let db: SQL.Database | null = null;
let dbPath: string | null = null; let dbPath: string | null = null;
let _hasFTS5 = false;
/** 获取数据库实例 */ /** 获取数据库实例 */
export function getDb(): SQL.Database { export function getDb(): SQL.Database {
@@ -171,24 +170,6 @@ export async function initDatabase(): Promise<SQL.Database> {
); );
CREATE INDEX IF NOT EXISTS idx_tool_calls_session ON tool_calls(session_id, tool_name); 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 ( CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
@@ -221,21 +202,6 @@ export async function initDatabase(): Promise<SQL.Database> {
try { db.run('ALTER TABLE traces ADD COLUMN error_pattern TEXT'); } catch { /* 列已存在,忽略 */ } 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(); persist();
@@ -271,20 +237,6 @@ export interface MessageRow {
created_at: number; 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 { export interface SettingRow {
key: string; 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 ─── // ─── Settings CRUD ───
export function saveSetting(key: string, value: unknown): void { export function saveSetting(key: string, value: unknown): void {
@@ -571,7 +436,6 @@ export function getAllSessionsTokenStats(): AllSessionsTokenStats {
export interface ExportData { export interface ExportData {
sessions: SessionRow[]; sessions: SessionRow[];
messages: MessageRow[]; messages: MessageRow[];
memories: MemoryRow[];
settings: Array<{ key: string; value: unknown }>; settings: Array<{ key: string; value: unknown }>;
exportedAt: number; exportedAt: number;
} }
@@ -580,10 +444,9 @@ export function exportAllSessions(): ExportData {
const d = getDb(); const d = getDb();
const sessions = queryAll(d, 'SELECT * FROM sessions') as unknown as SessionRow[]; const sessions = queryAll(d, 'SELECT * FROM sessions') as unknown as SessionRow[];
const messages = queryAll(d, 'SELECT * FROM messages') as unknown as MessageRow[]; 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 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; } })() })); 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 } { 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]); const sessionExists = queryOne(d, 'SELECT id FROM sessions WHERE id = ?', [msg.session_id]);
if (sessionExists) saveMessage(msg); 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) { for (const s of data.settings) {
saveSetting(s.key, s.value); saveSetting(s.key, s.value);
} }
+130 -31
View File
@@ -11,7 +11,6 @@ import { showNotification } from './utils.js';
import { import {
initDatabase, saveSession, getSession, getAllSessions, deleteSession, clearAllSessions, initDatabase, saveSession, getSession, getAllSessions, deleteSession, clearAllSessions,
saveMessage, getMessagesBySession, saveMessage, getMessagesBySession,
saveMemory, getMemory, getAllMemories, getMemoriesByType, deleteMemory, clearAllMemories, searchMemoriesFTS,
saveSetting, getSetting, saveSetting, getSetting,
saveToolCall, getToolCallsBySession, saveToolCall, getToolCallsBySession,
saveTrace, getTracesBySession, saveTrace, getTracesBySession,
@@ -387,36 +386,6 @@ export async function setupIPC(): Promise<void> {
catch { return []; } 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 // Settings
ipcMain.handle('db:saveSetting', (_, key: string, value: unknown) => { ipcMain.handle('db:saveSetting', (_, key: string, value: unknown) => {
try { saveSetting(key, value); return { success: true }; } try { saveSetting(key, value); return { success: true }; }
@@ -463,6 +432,110 @@ export async function setupIPC(): Promise<void> {
catch (err) { sendLog('error', '获取全局 Token 统计失败', (err as Error).message); return null; } 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 ── // ── MCP IPC ──
ipcMain.handle('mcp:startServer', async (_, config) => { ipcMain.handle('mcp:startServer', async (_, config) => {
return startServer(config); return startServer(config);
@@ -508,6 +581,32 @@ export async function setupIPC(): Promise<void> {
}); });
} }
/** 校验 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) ── // ── 视频帧提取 (ffmpeg) ──
interface ExtractedFrame { interface ExtractedFrame {
+1 -1
View File
@@ -101,7 +101,7 @@ export function createMenu(): void {
dialog.showMessageBox(mainWindow!, { dialog.showMessageBox(mainWindow!, {
type: 'info', type: 'info',
title: '关于 Metona Ollama', title: '关于 Metona Ollama',
message: 'Metona Ollama Desktop v0.12.11', message: 'Metona Ollama Desktop v0.13.0',
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama', detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
icon: getIconPath() icon: getIconPath()
}); });
+5 -7
View File
@@ -63,13 +63,6 @@ contextBridge.exposeInMainWorld('metonaDesktop', {
clearAllSessions: () => ipcRenderer.invoke('db:clearAllSessions'), clearAllSessions: () => ipcRenderer.invoke('db:clearAllSessions'),
saveMessage: (msg: unknown) => ipcRenderer.invoke('db:saveMessage', msg), saveMessage: (msg: unknown) => ipcRenderer.invoke('db:saveMessage', msg),
getMessages: (sessionId: string) => ipcRenderer.invoke('db:getMessages', sessionId), 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), saveSetting: (key: string, value: unknown) => ipcRenderer.invoke('db:saveSetting', key, value),
getSetting: (key: string, defaultValue?: unknown) => ipcRenderer.invoke('db:getSetting', key, defaultValue), getSetting: (key: string, defaultValue?: unknown) => ipcRenderer.invoke('db:getSetting', key, defaultValue),
saveToolCall: (tc: unknown) => ipcRenderer.invoke('db:saveToolCall', tc), saveToolCall: (tc: unknown) => ipcRenderer.invoke('db:saveToolCall', tc),
@@ -129,5 +122,10 @@ contextBridge.exposeInMainWorld('metonaDesktop', {
removeProgressListener: () => { removeProgressListener: () => {
ipcRenderer.removeAllListeners('video:progress'); ipcRenderer.removeAllListeners('video:progress');
} }
},
memoryAccess: {
read: () => ipcRenderer.invoke('memory:read'),
write: (content: string) => ipcRenderer.invoke('memory:write', content),
init: () => ipcRenderer.invoke('memory:init'),
} }
}); });
+27
View File
@@ -71,6 +71,9 @@ export interface CheckResult {
/** 路径安全豁免列表:这些路径即使匹配 BLOCKED_DIRS 也放行(如 app 自己的工作空间) */ /** 路径安全豁免列表:这些路径即使匹配 BLOCKED_DIRS 也放行(如 app 自己的工作空间) */
let blocklistExemptions: string[] = []; let blocklistExemptions: string[] = [];
/** 工作空间下受保护的文件名:任何工具都禁止直接读写,只能通过专用通道访问 */
const BLOCKED_FILES = new Set(['MEMORY.md']);
/** 注册路径为安全豁免(不受 BLOCKED_DIRS 限制)。主要用于注册工作空间目录 */ /** 注册路径为安全豁免(不受 BLOCKED_DIRS 限制)。主要用于注册工作空间目录 */
export function addBlocklistExemptions(dirs: string[]): void { export function addBlocklistExemptions(dirs: string[]): void {
for (const d of dirs) { for (const d of dirs) {
@@ -91,6 +94,24 @@ function isBlocklistExempt(resolved: string): boolean {
return false; 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 { export function checkPathAllowed(targetPath: string, operation: 'read' | 'write'): CheckResult {
const resolved = path.resolve(targetPath); const resolved = path.resolve(targetPath);
@@ -98,6 +119,12 @@ export function checkPathAllowed(targetPath: string, operation: 'read' | 'write'
return { ok: false, reason: '路径遍历深度过大' }; 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) { for (const blocked of BLOCKED_DIRS) {
if (resolved === blocked || resolved.startsWith(blocked + path.sep)) { if (resolved === blocked || resolved.startsWith(blocked + path.sep)) {
// 如果路径在安全豁免列表中(如 app 自己的工作空间),放行 // 如果路径在安全豁免列表中(如 app 自己的工作空间),放行
+2 -2
View File
@@ -254,7 +254,7 @@ function renderToolCallCard(tc: ToolCallRecord): string {
edit_file: '✂️', get_file_info: '️', tree: '🌳', download_file: '⬇️', edit_file: '✂️', get_file_info: '️', tree: '🌳', download_file: '⬇️',
diff_files: '🔀', replace_in_files: '🔄', read_multiple_files: '📚', diff_files: '🔀', replace_in_files: '🔄', read_multiple_files: '📚',
git: '🔖', compress: '🗜️', web_search: '🔍', 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_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰',
browser_click: '👆', browser_type: '⌨️', browser_scroll: '↕️', browser_close: '❌' 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: '下载文件', edit_file: '编辑文件', get_file_info: '文件信息', tree: '目录树', download_file: '下载文件',
diff_files: '文件对比', replace_in_files: '批量替换', read_multiple_files: '批量读取', diff_files: '文件对比', replace_in_files: '批量替换', read_multiple_files: '批量读取',
git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索', 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_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容',
browser_click: '点击元素', browser_type: '输入文本', browser_scroll: '滚动页面', browser_close: '关闭浏览器' browser_click: '点击元素', browser_type: '输入文本', browser_scroll: '滚动页面', browser_close: '关闭浏览器'
}; };
-1
View File
@@ -17,7 +17,6 @@ import { ChatDB } from '../db/chat-db.js';
import { OllamaAPI } from '../api/ollama.js'; import { OllamaAPI } from '../api/ollama.js';
import { runAgentLoop } from '../services/agent-engine.js'; import { runAgentLoop } from '../services/agent-engine.js';
import { estimateTokens } from '../services/context-manager.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 { showToolConfirm } from './tool-confirm-modal.js';
import { logInfo, logStream, logError, logSuccess, logWarn, resetVideoProgress, updateVideoProgress } from '../services/log-service.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'; import type { ChatSession, ChatMessage, OllamaStreamChunk, FileContent, ChatFile, ToolCallRecord, AgentMode } from '../types.js';
+67 -356
View File
@@ -1,24 +1,18 @@
/** /**
* MemoryModal - Agent 记忆管理大模态框 * MemoryModal - Agent 记忆管理(简化版)
* 左右分栏布局:左侧分类筛选+统计,右侧搜索+列表+操作 * 基于工作空间 MEMORY.md 文件
*/ */
import { import { loadAllEntries, addEntry, removeEntry, type MemoryEntry, type MemoryType } from '../services/memory-service.js';
getMemoryCache, searchMemories, addMemory, updateMemory, deleteMemory,
clearAllMemories, isMemoryEnabled, setMemoryEnabled,
getTypeIcon, getTypeName, isVectorMemoryEnabled, getEmbeddingModel
} from '../services/memory-manager.js';
import { showToast } from './toast.js'; import { showToast } from './toast.js';
import { showPrompt, showConfirm } from './prompt-modal.js'; import { showPrompt, showConfirm } from './prompt-modal.js';
import { debounce, escapeHtml, formatTime } from '../utils/utils.js'; import { escapeHtml, formatTime } from '../utils/utils.js';
import type { MemoryEntry, MemoryType } from '../types.js';
let modalEl: HTMLElement; let modalEl: HTMLElement;
// ── 初始化 ── // ── 初始化 ──
export function initMemoryModal(): void { export function initMemoryModal(): void {
// 创建模态框 DOM
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.className = 'modal-overlay'; overlay.className = 'modal-overlay';
overlay.id = 'memoryModal'; overlay.id = 'memoryModal';
@@ -26,12 +20,9 @@ export function initMemoryModal(): void {
overlay.innerHTML = ` overlay.innerHTML = `
<div class="modal"> <div class="modal">
<div class="modal-header"> <div class="modal-header">
<h3>🧠 Agent 记忆</h3> <h3>🧠 Agent 记忆 (MEMORY.md)</h3>
<div style="display:flex;align-items:center;gap:8px;"> <div style="display:flex;align-items:center;gap:8px;">
<label class="toggle-label" title="自动记忆"> <button class="btn btn-sm btn-outline" id="btnAddMemoryLg"> 添加</button>
<input type="checkbox" id="memoryModalToggle" checked>
<span class="toggle-slider"></span>
</label>
<button class="icon-btn" id="btnCloseMemoryModal"> <button class="icon-btn" id="btnCloseMemoryModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/> <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
@@ -39,65 +30,9 @@ export function initMemoryModal(): void {
</button> </button>
</div> </div>
</div> </div>
<div class="modal-body"> <div class="modal-body" style="max-height:65vh;overflow-y:auto;">
<div class="memory-layout"> <div class="memory-list-simple" id="memoryListSimple"></div>
<!-- 左侧边栏 --> <div class="memory-footer-simple" id="memoryFooterSimple" style="text-align:center;padding:16px;color:var(--text-muted);font-size:12px;"></div>
<div class="memory-sidebar">
<div class="memory-stats" id="memoryStats">
<div class="memory-stats-item">
<span>总计</span>
<span class="memory-stats-count" id="memStatTotal">0</span>
</div>
<div class="memory-stats-item">
<span>📌 事实</span>
<span class="memory-stats-count" id="memStatFact">0</span>
</div>
<div class="memory-stats-item">
<span>⚙️ 偏好</span>
<span class="memory-stats-count" id="memStatPref">0</span>
</div>
<div class="memory-stats-item">
<span>📏 规则</span>
<span class="memory-stats-count" id="memStatRule">0</span>
</div>
</div>
<div class="memory-categories" id="memoryCategories">
<div class="memory-category-item active" data-type="">
<span class="memory-category-icon">📋</span>
<span>全部</span>
<span class="memory-category-count" id="catCountAll">0</span>
</div>
<div class="memory-category-item" data-type="fact">
<span class="memory-category-icon">📌</span>
<span>事实</span>
<span class="memory-category-count" id="catCountFact">0</span>
</div>
<div class="memory-category-item" data-type="preference">
<span class="memory-category-icon">⚙️</span>
<span>偏好</span>
<span class="memory-category-count" id="catCountPref">0</span>
</div>
<div class="memory-category-item" data-type="rule">
<span class="memory-category-icon">📏</span>
<span>规则</span>
<span class="memory-category-count" id="catCountRule">0</span>
</div>
</div>
</div>
<!-- 右侧主区域 -->
<div class="memory-main">
<div class="memory-toolbar">
<div class="memory-search-wrap">
<input type="text" id="memorySearchLg" class="memory-search-input-lg" placeholder="搜索记忆(支持关键词和语义搜索)...">
</div>
<span id="memoryVectorBadge" class="memory-vector-badge disabled">关键词模式</span>
<button class="btn btn-sm btn-outline" id="btnImportMemoryMd" disabled title="需先在设置中配置嵌入模型">📥 导入文档</button>
<button class="btn btn-sm btn-outline" id="btnAddMemoryLg"> 添加</button>
<button class="btn btn-sm btn-danger-outline" id="btnClearMemoryLg">清空</button>
</div>
<div class="memory-list-lg" id="memoryListLg"></div>
</div>
</div>
</div> </div>
</div> </div>
`; `;
@@ -105,298 +40,96 @@ export function initMemoryModal(): void {
document.querySelector('#app')!.appendChild(overlay); document.querySelector('#app')!.appendChild(overlay);
modalEl = overlay; modalEl = overlay;
// 绑定事件
overlay.addEventListener('click', (e) => { overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeMemoryModal(); if (e.target === overlay) closeMemoryModal();
}); });
overlay.querySelector('#btnCloseMemoryModal')!.addEventListener('click', 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); overlay.querySelector('#btnAddMemoryLg')!.addEventListener('click', openAddDialog);
// 导入 Markdown 记忆文档 // 列表点击:删除按钮
overlay.querySelector('#btnImportMemoryMd')!.addEventListener('click', importMemoryFromMd); overlay.querySelector('#memoryListSimple')!.addEventListener('click', async (e) => {
const target = e.target as HTMLElement;
// 清空 if (target.classList.contains('memory-item-delete')) {
overlay.querySelector('#btnClearMemoryLg')!.addEventListener('click', async () => { const id = target.dataset.id!;
if (await showConfirm('确定清空所有记忆?此操作不可恢复!', '清空记忆')) { const entries = await loadAllEntries();
await clearAllMemories(); 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(); renderList();
updateStats(); showToast('记忆已删除', 'info', 1500);
showToast('已清空所有记忆', 'success'); }
} }
}); });
// header 按钮 // header 按钮
document.querySelector('#btnMemory')?.addEventListener('click', openMemoryModal); 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<void> {
modalEl.style.display = ''; modalEl.style.display = '';
(modalEl.querySelector('#memoryModalToggle') as HTMLInputElement).checked = isMemoryEnabled(); await renderList();
updateVectorBadge();
updateStats();
renderList();
(modalEl.querySelector('#memorySearchLg') as HTMLInputElement).focus();
} }
export function closeMemoryModal(): void { export function closeMemoryModal(): void {
modalEl.style.display = 'none'; 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<string, number> = { 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 { async function renderList(): Promise<void> {
const active = modalEl.querySelector('.memory-category-item.active') as HTMLElement; const listEl = modalEl.querySelector('#memoryListSimple') as HTMLElement;
return active?.dataset.type || ''; const footerEl = modalEl.querySelector('#memoryFooterSimple') as HTMLElement;
}
function renderList(): void {
const listEl = modalEl.querySelector('#memoryListLg')!;
const query = (modalEl.querySelector('#memorySearchLg') as HTMLInputElement).value.trim();
const typeFilter = getActiveType();
let entries: MemoryEntry[]; let entries: MemoryEntry[];
if (query) { try {
entries = searchMemories(query, 50); entries = await loadAllEntries();
} else { } catch (err) {
entries = getMemoryCache(); listEl.innerHTML = `<div style="text-align:center;padding:20px;color:var(--text-muted);">加载失败: ${escapeHtml((err as Error).message)}</div>`;
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) { if (entries.length === 0) {
listEl.innerHTML = `<div class="memory-empty-lg">${query ? '未找到匹配的记忆' : '暂无记忆,AI 会自动在对话中学习'}</div>`; listEl.innerHTML = `<div style="text-align:center;padding:32px 20px;color:var(--text-muted);">
<div style="font-size:48px;margin-bottom:12px;">🧠</div>
<div style="font-size:14px;">暂无记忆</div>
<div style="font-size:12px;margin-top:8px;color:var(--text-tertiary);">AI 在对话中可通过 memory 工具自动管理记忆<br>工作空间 MEMORY.md 文件存储</div>
</div>`;
footerEl.textContent = '在对话中让 AI "记住xxx"即可自动添加记忆';
return; return;
} }
listEl.innerHTML = entries.map(entry => { // 按类型分组渲染
const icon = getTypeIcon(entry.type); const typeOrder: MemoryType[] = ['rule', 'preference', 'fact'];
const typeName = getTypeName(entry.type); const typeIcons: Record<string, string> = { rule: '📏', preference: '⚙️', fact: '📌' };
const importanceStars = '★'.repeat(Math.min(entry.importance, 10)) + '☆'.repeat(Math.max(0, 10 - entry.importance)); const typeNames: Record<string, string> = { rule: '规则', preference: '偏好', fact: '事实' };
return `
<div class="memory-item-lg" data-id="${entry.id}"> let html = '';
<div class="memory-item-lg-header"> for (const type of typeOrder) {
<span>${icon}</span> const typeEntries = entries.filter(e => e.type === type);
<span class="memory-item-lg-type">${typeName}</span> if (typeEntries.length === 0) continue;
<span class="memory-item-lg-importance">${importanceStars}</span>
<button class="memory-item-lg-delete" data-id="${entry.id}" title="删除">✕</button> html += `<div style="font-size:12px;font-weight:700;color:var(--text-secondary);padding:8px 0 4px;margin-top:8px;">${typeIcons[type]} ${typeNames[type]} (${typeEntries.length})</div>`;
for (const entry of typeEntries) {
const stars = '★'.repeat(Math.min(entry.importance, 10));
html += `<div class="memory-entry-simple" style="padding:10px 14px;margin:4px 0;background:var(--bg-card);border-radius:var(--radius-control);border:1px solid var(--border-subtle);">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;">
<div style="flex:1;font-size:13px;line-height:1.5;color:var(--text-primary);">${escapeHtml(entry.content)}</div>
<button class="memory-item-delete" data-id="${entry.id}" style="flex-shrink:0;background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:16px;padding:0 4px;line-height:1;" title="删除">✕</button>
</div> </div>
<div class="memory-item-lg-content">${escapeHtml(entry.content)}</div> <div style="display:flex;gap:8px;align-items:center;margin-top:6px;font-size:11px;color:var(--text-tertiary);">
<div class="memory-item-lg-meta"> <span style="color:var(--accent);">${stars}</span>
${entry.tags.length > 0 ? `<span class="memory-item-lg-tags">${entry.tags.map(t => `<span class="memory-tag-lg">${escapeHtml(t)}</span>`).join('')}</span>` : ''} <span>${entry.id}</span>
<span class="memory-item-lg-time">${formatTime(entry.updatedAt)}</span> ${entry.tags.length > 0 ? `<span>🏷 ${entry.tags.join(', ')}</span>` : ''}
</div> </div>
</div> </div>`;
`;
}).join('');
// 删除按钮
listEl.querySelectorAll('.memory-item-lg-delete').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const id = (btn as HTMLElement).dataset.id!;
await deleteMemory(id);
renderList();
updateStats();
showToast('记忆已删除', 'info', 1500);
});
});
// 双击编辑
listEl.querySelectorAll('.memory-item-lg-content').forEach(el => {
el.addEventListener('dblclick', () => {
const item = (el as HTMLElement).closest('.memory-item-lg')!;
const id = item.getAttribute('data-id')!;
const entry = getMemoryCache().find(e => e.id === id);
if (entry) openEditDialog(entry);
});
});
}
// ── 导入 Markdown 记忆文档 ──
async function importMemoryFromMd(): Promise<void> {
if (!isVectorMemoryEnabled()) {
showToast('请先在设置中配置嵌入模型以启用向量记忆', 'warning');
return;
}
const bridge = window.metonaDesktop;
if (!bridge || !bridge.isDesktop) {
showToast('导入功能仅在桌面端可用', 'warning');
return;
}
const paths = await bridge.dialog.openFile({
filters: [{ name: 'Markdown 文档', extensions: ['md'] }]
});
if (!paths || paths.length === 0) return;
const filePath = paths[0];
const fileName = filePath.split(/[/\\]/).pop() || filePath;
try {
const result = await bridge.fs.readFile(filePath);
if (!result.success) {
showToast(`读取失败: ${result.error}`, 'error');
return;
}
const content = result.content as string;
const sections = parseMdToMemories(content);
if (sections.length === 0) {
showToast('未从文档中解析到有效记忆内容', 'warning');
return;
}
let imported = 0;
let skipped = 0;
for (const sec of sections) {
if (!sec.content || sec.content.length < 5) { skipped++; continue; }
await addMemory({
type: sec.type || 'fact',
content: sec.content,
importance: sec.importance || 5,
tags: sec.tags || [],
source: fileName
});
imported++;
}
renderList();
updateStats();
showToast(`已导入 ${imported} 条记忆(已向量化)${skipped > 0 ? `,跳过 ${skipped}` : ''}`, 'success', 3000);
} catch (err) {
showToast(`导入失败: ${(err as Error).message}`, 'error');
}
}
function parseMdToMemories(md: string): Array<{ type: MemoryType; content: string; importance: number; tags: string[] }> {
const results: Array<{ type: MemoryType; content: string; importance: number; tags: string[] }> = [];
// 按 ## 二级标题分块
const blocks = md.split(/^## /m).filter(b => b.trim());
for (const block of blocks) {
const lines = block.trim().split('\n');
const heading = lines[0]?.trim() || '';
const body = lines.slice(1).join('\n').trim();
if (!body && !heading) continue;
// 从标题推断类型
let type: MemoryType = 'fact';
const headingLower = heading.toLowerCase();
if (headingLower.includes('偏好') || headingLower.includes('preference') || headingLower.includes('风格') || headingLower.includes('习惯')) {
type = 'preference';
} else if (headingLower.includes('规则') || headingLower.includes('rule') || headingLower.includes('规范') || headingLower.includes('约束')) {
type = 'rule';
}
// 提取标签:标题中的关键词 + 正文前几个有意义的词
const tags: string[] = [];
if (heading) {
const headingTags = heading.replace(/[^\w\u4e00-\u9fff\s]/g, ' ').split(/\s+/).filter(w => w.length > 1);
tags.push(...headingTags.slice(0, 3));
}
// 内容:标题 + 正文(如果正文为空用标题作内容)
const content = body ? `${heading ? heading + '' : ''}${body}` : heading;
if (content.length < 5) continue;
// 从标题提取重要性(如 "重要性:8" 或 "importance: 7"
let importance = 5;
const impMatch = heading.match(/(?:重要性|importance)\s*[:]\s*(\d+)/i);
if (impMatch) importance = Math.min(10, Math.max(1, parseInt(impMatch[1]) || 5));
results.push({ type, content: content.slice(0, 500), importance, tags });
}
// 如果按标题分块无结果,按段落分块
if (results.length === 0) {
const paragraphs = md.split(/\n{2,}/).filter(p => p.trim());
for (const para of paragraphs) {
const text = para.trim().replace(/^[-*]\s+/gm, '').replace(/^#+\s+/gm, '');
if (text.length < 10) continue;
results.push({ type: 'fact', content: text.slice(0, 500), importance: 5, tags: [] });
} }
} }
return results; listEl.innerHTML = html;
footerEl.textContent = `${entries.length} 条记忆 · 存储于工作空间 MEMORY.md`;
} }
// ── 添加对话框 ── // ── 添加对话框 ──
@@ -432,33 +165,11 @@ async function openAddDialog(): Promise<void> {
}); });
const importance = Math.min(10, Math.max(1, parseInt(importanceStr || '5') || 5)); const importance = Math.min(10, Math.max(1, parseInt(importanceStr || '5') || 5));
await addMemory({ type: memoryType, content: content.trim(), importance }); try {
renderList(); await addEntry(memoryType, content.trim(), importance);
updateStats(); await renderList();
showToast('记忆已添加', 'success', 1500); showToast('记忆已添加', 'success', 1500);
} } catch (err) {
showToast(`添加失败: ${(err as Error).message}`, 'error');
// ── 编辑对话框 ── }
async function openEditDialog(entry: MemoryEntry): Promise<void> {
const newContent = await showPrompt({
title: '编辑记忆',
message: '修改记忆内容:',
type: 'textarea',
defaultValue: entry.content
});
if (newContent === null || newContent.trim() === entry.content) return;
const importanceStr = await showPrompt({
title: '编辑记忆',
message: '重要性(1-10):',
defaultValue: String(entry.importance),
placeholder: String(entry.importance)
});
const importance = importanceStr ? Math.min(10, Math.max(1, parseInt(importanceStr) || entry.importance)) : entry.importance;
await updateMemory(entry.id, { content: newContent.trim(), importance });
renderList();
updateStats();
showToast('记忆已更新', 'success', 1500);
} }
+4 -89
View File
@@ -5,7 +5,6 @@
import { state, KEYS } from '../state/state.js'; import { state, KEYS } from '../state/state.js';
import { debounce } from '../utils/utils.js'; import { debounce } from '../utils/utils.js';
import { encryptData, decryptData } from '../services/crypto.js'; import { encryptData, decryptData } from '../services/crypto.js';
import { setMemoryEnabled, setEmbeddingModel, getEmbeddingModel, getMemoryCache } from '../services/memory-manager.js';
import { logSetting, logInfo, logWarn, logError, logSuccess } from '../services/log-service.js'; import { logSetting, logInfo, logWarn, logError, logSuccess } from '../services/log-service.js';
import { checkConnection, updateConnectionInfo, updateRunningModels } from './header.js'; import { checkConnection, updateConnectionInfo, updateRunningModels } from './header.js';
import { loadModels } from './model-bar.js'; import { loadModels } from './model-bar.js';
@@ -224,33 +223,6 @@ export function initSettingsModal(): void {
}); });
// ── Agent 记忆设置 ──
const toggleMemory = document.querySelector('#toggleMemoryEnabled') as HTMLInputElement;
if (toggleMemory) {
toggleMemory.addEventListener('change', () => {
setMemoryEnabled(toggleMemory.checked);
showToast(toggleMemory.checked ? 'Agent 记忆已开启' : 'Agent 记忆已关闭', 'info');
logSetting('Agent 记忆', toggleMemory.checked ? '开启' : '关闭');
});
}
// ── 向量记忆引擎设置 ──
const selectEmbedModel = document.querySelector('#selectMemoryEmbedModel') as HTMLSelectElement;
if (selectEmbedModel) {
selectEmbedModel.addEventListener('change', async () => {
const model = selectEmbedModel.value;
await setEmbeddingModel(model);
if (model) {
showToast(`向量记忆已启用(${model}`, 'success');
logSetting('向量记忆引擎', `启用: ${model}`);
} else {
showToast('向量记忆已关闭,仅使用关键词匹配', 'info');
logSetting('向量记忆引擎', '关闭');
}
updateMemoryVectorStatus();
});
}
// ── 工作空间目录设置 ── // ── 工作空间目录设置 ──
const inputWorkspaceDir = document.querySelector('#inputWorkspaceDir') as HTMLInputElement; const inputWorkspaceDir = document.querySelector('#inputWorkspaceDir') as HTMLInputElement;
const btnBrowseWorkspace = document.querySelector('#btnBrowseWorkspace') as HTMLButtonElement; const btnBrowseWorkspace = document.querySelector('#btnBrowseWorkspace') as HTMLButtonElement;
@@ -290,6 +262,10 @@ export function initSettingsModal(): void {
logSetting('工作空间目录', result.dir!); logSetting('工作空间目录', result.dir!);
// ── 通知工作空间面板刷新目录 ── // ── 通知工作空间面板刷新目录 ──
window.dispatchEvent(new CustomEvent('workspaceDirChanged', { detail: { dir: result.dir } })); window.dispatchEvent(new CustomEvent('workspaceDirChanged', { detail: { dir: result.dir } }));
// ── 初始化新工作空间的 MEMORY.md ──
import('../services/memory-service.js').then(({ initMemoryFile }) => {
initMemoryFile().catch(() => {});
});
} else { } else {
showToast(`设置失败: ${result.error!}`, 'error'); showToast(`设置失败: ${result.error!}`, 'error');
// 恢复原值 // 恢复原值
@@ -305,9 +281,6 @@ export function openSettingsModal(): void {
(document.querySelector('#inputServerUrl') as HTMLInputElement).value = state.get<OllamaAPI>(KEYS.API)?.baseUrl || ''; (document.querySelector('#inputServerUrl') as HTMLInputElement).value = state.get<OllamaAPI>(KEYS.API)?.baseUrl || '';
updateConnectionInfo(); updateConnectionInfo();
updateRunningModels(); updateRunningModels();
populateMemoryEmbedModels();
populateSubAgentModels();
updateMemoryVectorStatus();
loadTimeoutSettings(); loadTimeoutSettings();
loadWatchdogSetting(); loadWatchdogSetting();
// 刷新工作空间目录显示 // 刷新工作空间目录显示
@@ -413,50 +386,6 @@ async function exportAllSessions(): Promise<void> {
} }
} }
async function populateMemoryEmbedModels(): Promise<void> {
const select = document.querySelector('#selectMemoryEmbedModel') as HTMLSelectElement;
if (!select) return;
const api = state.get<OllamaAPI>(KEYS.API);
if (!api) return;
try {
const data = await api.listModels();
select.innerHTML = '<option value="">未选择(仅关键词模式)</option>';
if (!data.models || data.models.length === 0) return;
// 获取每个模型的能力信息,筛选嵌入模型
const checks = await Promise.allSettled(
data.models.map(async (m) => {
const info = await api.showModel(m.name);
const caps = info.capabilities || [];
return { name: m.name, size: m.size, isEmbed: caps.includes('embedding') };
})
);
const embedModels = checks
.filter(r => r.status === 'fulfilled' && r.value.isEmbed)
.map(r => (r as PromiseFulfilledResult<{ name: string; size?: number; isEmbed: boolean }>).value);
embedModels.forEach(m => {
const opt = document.createElement('option');
opt.value = m.name;
opt.textContent = m.name;
select.appendChild(opt);
});
if (embedModels.length === 0) {
select.innerHTML = '<option value="">未检测到嵌入模型,请先 ollama pull</option>';
return;
}
// 恢复已保存的选择
const saved = getEmbeddingModel();
if (saved) select.value = saved;
} catch (err) {
logWarn('加载嵌入模型列表失败', (err as Error).message);
}
}
async function populateSubAgentModels(): Promise<void> { async function populateSubAgentModels(): Promise<void> {
const select = document.querySelector('#selectSubAgentModel') as HTMLSelectElement; const select = document.querySelector('#selectSubAgentModel') as HTMLSelectElement;
if (!select) return; if (!select) return;
@@ -533,20 +462,6 @@ const saveLoopWatchdog = debounce(async () => {
}, 500); }, 500);
document.querySelector('#inputLoopWatchdog')!.addEventListener('input', saveLoopWatchdog); document.querySelector('#inputLoopWatchdog')!.addEventListener('input', saveLoopWatchdog);
function updateMemoryVectorStatus(): void {
const statusEl = document.querySelector('#memoryVectorStatus');
if (!statusEl) return;
const model = getEmbeddingModel();
if (model) {
const memCount = getMemoryCache().length;
statusEl.textContent = `✅ 向量搜索已启用(${model})· ${memCount} 条记忆`;
(statusEl as HTMLElement).style.color = 'var(--success)';
} else {
statusEl.textContent = 'ℹ️ 未选择嵌入模型,仅使用关键词匹配';
(statusEl as HTMLElement).style.color = '';
}
}
async function importSessions(filePath: string): Promise<void> { async function importSessions(filePath: string): Promise<void> {
const db = state.get<ChatDB | null>(KEYS.DB); const db = state.get<ChatDB | null>(KEYS.DB);
if (!db) return; if (!db) return;
+3 -3
View File
@@ -961,7 +961,7 @@ function renderToolCalls(): void {
</div> </div>
<div class="ws-idle-divider"></div> <div class="ws-idle-divider"></div>
<div class="ws-idle-desc">AI 对话中自动调用工具,结果在此展示</div> <div class="ws-idle-desc">AI 对话中自动调用工具,结果在此展示</div>
<div class="ws-idle-hint">共 44 个内置工具 · MCP 动态扩展</div> <div class="ws-idle-hint">共 40 个内置工具 · MCP 动态扩展</div>
</div> </div>
`; `;
return; return;
@@ -983,7 +983,7 @@ function getToolDisplayName(name: string): string {
edit_file: '编辑文件', get_file_info: '文件信息', tree: '目录树', download_file: '下载文件', edit_file: '编辑文件', get_file_info: '文件信息', tree: '目录树', download_file: '下载文件',
diff_files: '文件对比', replace_in_files: '批量替换', read_multiple_files: '批量读取', diff_files: '文件对比', replace_in_files: '批量替换', read_multiple_files: '批量读取',
git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索', git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索',
memory_search: '搜索记忆', memory_add: '添加记忆', memory_replace: '替换记忆', memory_remove: '删除记忆', memory: '记忆管理',
session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派', plan_track: '计划追踪', session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派', plan_track: '计划追踪',
browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容', 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: '⬇️', edit_file: '✂️', get_file_info: '️', tree: '🌳', download_file: '⬇️',
diff_files: '🔀', replace_in_files: '🔄', read_multiple_files: '📚', diff_files: '🔀', replace_in_files: '🔄', read_multiple_files: '📚',
git: '🔖', compress: '🗜️', web_search: '🔍', git: '🔖', compress: '🗜️', web_search: '🔍',
memory_search: '🧠', memory_add: '💾', memory_replace: '✏️', memory_remove: '🗑️', memory: '🧠',
session_list: '📋', session_read: '📖', spawn_task: '🤖', plan_track: '📋', session_list: '📋', session_read: '📖', spawn_task: '🤖', plan_track: '📋',
browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰', browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰',
+1 -137
View File
@@ -3,7 +3,7 @@
* v4.0: 桌面端走 IPC → SQLiteWeb 端仍用 IndexedDB * v4.0: 桌面端走 IPC → SQLiteWeb 端仍用 IndexedDB
*/ */
import type { ChatSession, MemoryEntry } from '../types.js'; import type { ChatSession } from '../types.js';
/** 检查是否在桌面环境 */ /** 检查是否在桌面环境 */
function isDesktop(): boolean { function isDesktop(): boolean {
@@ -217,89 +217,8 @@ export class ChatDB {
return this._idbGetSetting(key, defaultValue); return this._idbGetSetting(key, defaultValue);
} }
// ── Memories ──
async saveMemory(entry: MemoryEntry): Promise<string> {
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<MemoryEntry | null> {
if (isDesktop()) {
const row = await dbBridge().getMemory(id);
return row ? this._rowToMemory(row) : null;
}
return this._idbGetMemory(id);
}
async getAllMemories(): Promise<MemoryEntry[]> {
if (isDesktop()) {
const rows = await dbBridge().getAllMemories();
return rows.map((r: any) => this._rowToMemory(r));
}
return this._idbGetAllMemories();
}
async getMemoriesByType(type: string): Promise<MemoryEntry[]> {
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<void> {
if (isDesktop()) {
await dbBridge().deleteMemory(id);
return;
}
return this._idbDeleteMemory(id);
}
async clearAllMemories(): Promise<void> {
if (isDesktop()) {
await dbBridge().clearAllMemories();
return;
}
return this._idbClearAllMemories();
}
// ── Helpers ── // ── 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 端兼容) // IndexedDB fallback methods (Web 端兼容)
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
@@ -393,61 +312,6 @@ export class ChatDB {
}); });
} }
private async _idbSaveMemory(entry: MemoryEntry): Promise<string> {
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<MemoryEntry | null> {
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<MemoryEntry[]> {
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<MemoryEntry[]> {
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<void> {
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<void> {
return new Promise((resolve, reject) => {
const store = this._idbTx('memories', 'readwrite');
const req = store.clear();
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
/** 获取原始 IndexedDB 实例(用于数据迁移) */ /** 获取原始 IndexedDB 实例(用于数据迁移) */
getRawIDB(): IDBDatabase | null { getRawIDB(): IDBDatabase | null {
return this.idb; return this.idb;
+24 -45
View File
@@ -28,7 +28,7 @@
<div class="header-left"> <div class="header-left">
<span class="logo">🦙</span> <span class="logo">🦙</span>
<span class="app-title">Metona Ollama</span> <span class="app-title">Metona Ollama</span>
<span class="app-version">v0.12.11</span> <span class="app-version">v0.13.0</span>
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助"> <button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/> <circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
@@ -341,28 +341,14 @@
<p class="text-muted" style="margin-top:4px;font-size:11px;">工作空间用于存放 AI 执行命令时的工作文件。命令行默认在此目录执行。</p> <p class="text-muted" style="margin-top:4px;font-size:11px;">工作空间用于存放 AI 执行命令时的工作文件。命令行默认在此目录执行。</p>
</div> </div>
<div class="setting-group"> <div class="setting-group">
<label class="setting-label"> <label class="setting-label">🧠 Agent 记忆系统</label>
🧠 Agent 记忆系统 <p class="text-muted" style="font-size:11px;">记忆存储在<strong>工作空间 MEMORY.md</strong> 文件中。新对话时自动检索注入上下文,对话结束时 AI 自动提取保存(多层质量过滤,宁缺毋滥)。受路径保护,仅 <code>memory</code> 工具可读写。</p>
<label class="toggle-label" style="margin-left:auto;display:inline-flex;">
<input type="checkbox" id="toggleMemoryEnabled" checked>
<span class="toggle-slider"></span>
</label>
</label>
<p class="text-muted" style="font-size:11px;">启用后,AI 会自动从对话中提取关键信息(事实、偏好、规则)并跨会话记住。发送消息时自动检索相关记忆注入上下文。</p>
<div class="tool-list" style="margin-top:8px;"> <div class="tool-list" style="margin-top:8px;">
<div class="tool-item"><span>📌 事实</span><span class="text-muted">项目信息、身份背景</span></div> <div class="tool-item"><span>📌 事实</span><span class="text-muted">项目信息、身份背景</span></div>
<div class="tool-item"><span>⚙️ 偏好</span><span class="text-muted">语言风格、技术栈偏好</span></div> <div class="tool-item"><span>⚙️ 偏好</span><span class="text-muted">语言风格、技术栈偏好</span></div>
<div class="tool-item"><span>📏 规则</span><span class="text-muted">编码规范、输出格式要求</span></div> <div class="tool-item"><span>📏 规则</span><span class="text-muted">编码规范、输出格式要求</span></div>
</div> </div>
</div> </div>
<div class="setting-group">
<label class="setting-label">🧠 向量记忆引擎</label>
<p class="text-muted" style="font-size:11px;margin-bottom:8px;">选择嵌入模型以启用向量语义记忆。未选择时仅使用关键词匹配。</p>
<select class="setting-input" id="selectMemoryEmbedModel">
<option value="">未选择(仅关键词模式)</option>
</select>
<div id="memoryVectorStatus" class="text-muted" style="font-size:11px;margin-top:4px;"></div>
</div>
<div class="setting-group"> <div class="setting-group">
<label class="setting-label">🤖 子代理默认模型</label> <label class="setting-label">🤖 子代理默认模型</label>
<p class="text-muted" style="font-size:11px;margin-bottom:8px;">子代理(spawn_task)使用的默认模型。留空则使用当前选中的模型。可为简单任务选择更快的模型。</p> <p class="text-muted" style="font-size:11px;margin-bottom:8px;">子代理(spawn_task)使用的默认模型。留空则使用当前选中的模型。可为简单任务选择更快的模型。</p>
@@ -371,18 +357,23 @@
</select> </select>
</div> </div>
<div class="setting-group"> <div class="setting-group">
<label class="setting-label">🤖 子代理参数</label> <label class="setting-label">🤖 子代理 & 看门狗参数</label>
<p class="text-muted" style="font-size:11px;margin-bottom:8px;">最大轮次(留空=10轮)和超时(留空=300s,填0=禁用)</p> <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div style="display:flex;gap:16px;align-items:center;"> <div>
<span style="font-size:13px;">最大轮次</span> <label style="font-size:12px;color:var(--text-secondary);" for="inputSubAgentMaxLoops">🔄 子代理最大轮次</label>
<input type="number" id="inputSubAgentMaxLoops" class="setting-input" placeholder="10" min="1" max="100" step="1" style="width:80px;"> <input class="setting-input" id="inputSubAgentMaxLoops" type="number" min="1" max="100" step="1" placeholder="10" style="margin-bottom:0;">
<span style="font-size:13px;">超时(秒)</span> <p class="text-muted" style="font-size:10px;">spawn_task 子代理的最大工具调用轮次</p>
<input type="number" id="inputSubAgentTimeout" class="setting-input" placeholder="300" min="0" max="3600" step="30" style="width:80px;"> </div>
<div>
<label style="font-size:12px;color:var(--text-secondary);" for="inputSubAgentTimeout">⏱️ 子代理超时(秒)</label>
<input class="setting-input" id="inputSubAgentTimeout" type="number" min="0" max="3600" step="30" placeholder="300" style="margin-bottom:0;">
<p class="text-muted" style="font-size:10px;">子代理单次任务最大执行时间,0=禁用</p>
</div>
<div style="grid-column:1/-1;">
<label style="font-size:12px;color:var(--text-secondary);" for="inputLoopWatchdog">🐕 看门狗超时(分钟)</label>
<input class="setting-input" id="inputLoopWatchdog" type="number" min="0" max="120" step="5" placeholder="30" style="margin-bottom:0;">
<p class="text-muted" style="font-size:10px;">Agent Loop 全局超时保护,超过后强制终止。0=禁用,默认30分钟</p>
</div> </div>
<div style="display:flex;gap:16px;align-items:center;margin-top:8px;">
<span style="font-size:13px;">看门狗(分)</span>
<input type="number" id="inputLoopWatchdog" class="setting-input" placeholder="30" min="0" max="120" step="5" style="width:80px;">
<span class="text-muted" style="font-size:11px;">0=禁用,默认30分钟</span>
</div> </div>
</div> </div>
<div class="setting-group"> <div class="setting-group">
@@ -448,8 +439,8 @@
<div class="modal-body"> <div class="modal-body">
<div class="help-section"><h4>🚀 快速开始</h4><ol><li>确保 Ollama 已启动(默认 <code>http://127.0.0.1:11434</code>,可在设置中修改)</li><li>顶部模型栏选择一个模型,右侧会显示模型能力徽章(🧠 Think / 👁️ Vision / 🔧 Tools</li><li>输入消息,按 <kbd>Enter</kbd> 发送,<kbd>Shift+Enter</kbd> 换行</li></ol></div> <div class="help-section"><h4>🚀 快速开始</h4><ol><li>确保 Ollama 已启动(默认 <code>http://127.0.0.1:11434</code>,可在设置中修改)</li><li>顶部模型栏选择一个模型,右侧会显示模型能力徽章(🧠 Think / 👁️ Vision / 🔧 Tools</li><li>输入消息,按 <kbd>Enter</kbd> 发送,<kbd>Shift+Enter</kbd> 换行</li></ol></div>
<div class="help-section"><h4>💬 聊天功能</h4><ul><li><strong>流式回复</strong> — 实时打字效果,随时点 ■ 停止</li><li><strong>Think 推理</strong> — 下方 Think 按钮切换,让模型展示深度思考过程(需模型支持,如 Qwen3)</li><li><strong>上下文长度自动检测</strong> — 切换模型时自动从模型元数据获取实际支持的上下文长度(如 Qwen3.6 27B = 262,144 tokens),无需手动配置</li><li><strong>多模态</strong> — 上传图片或视频(≤10MB),模型需支持 Vision。图片自动压缩,视频 1fps 提取帧序列带时序标注</li><li><strong>文件分析</strong> — 支持 50+ 种文本/代码格式,单文件 ≤500KB,自动剥离注释并按上下文预算智能截断</li><li><strong>AI 回复顶部</strong> — 每条 AI 回复上方展示 📋 系统提示词折叠卡片,可点击查看实际发送给模型的完整上下文</li></ul></div> <div class="help-section"><h4>💬 聊天功能</h4><ul><li><strong>流式回复</strong> — 实时打字效果,随时点 ■ 停止</li><li><strong>Think 推理</strong> — 下方 Think 按钮切换,让模型展示深度思考过程(需模型支持,如 Qwen3)</li><li><strong>上下文长度自动检测</strong> — 切换模型时自动从模型元数据获取实际支持的上下文长度(如 Qwen3.6 27B = 262,144 tokens),无需手动配置</li><li><strong>多模态</strong> — 上传图片或视频(≤10MB),模型需支持 Vision。图片自动压缩,视频 1fps 提取帧序列带时序标注</li><li><strong>文件分析</strong> — 支持 50+ 种文本/代码格式,单文件 ≤500KB,自动剥离注释并按上下文预算智能截断</li><li><strong>AI 回复顶部</strong> — 每条 AI 回复上方展示 📋 系统提示词折叠卡片,可点击查看实际发送给模型的完整上下文</li></ul></div>
<div class="help-section"><h4>🔧 Tool Calling(始终开启)</h4><ul><li>所有消息均通过 <strong>Agent Loop 自主调用本地工具</strong>,像一个本地 Agent,无普通聊天模式</li><li><strong>42+1 个工具</strong>,分为 9 类:<ul><li><strong>文件系统</strong>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</li><li><strong>命令执行</strong>1 个):run_command(实时流式输出,支持自动/需确认/禁用三种模式,可配置超时)</li><li><strong>联网搜索</strong>2 个):web_search(支持 SearXNG 元搜索引擎 JSON API / 四引擎 HTML 解析,双模式可切换)/ web_fetch(反爬headers+UA自动切换+自动回退浏览器渲染)</li><li><strong>Git</strong>(1 个):git17 个子操作,push/pull/clone 内置60-120s超时保护)</li><li><strong>浏览器控制</strong>9 个):browser_open / browser_screenshot / browser_evaluate / browser_extract / browser_click / browser_type / browser_scroll / browser_wait / browser_close</li><li><strong>记忆 & 会话</strong>6 个):memory_search / memory_add / memory_replace / memory_remove / session_list / session_read</li><li><strong>子代理</strong>1 个):spawn_task</li><li><strong>系统工具</strong>6 个):datetime / calculator / random / uuid / json_format / hash</li></ul></li><li>read_file 支持 binary模式读取/base64解码/字节分页/2000行默认+截断hint自动续读</li><li>write_file 支持 base64写二进制文件/mode(overwrite+append)合并append_file功能/10MB</li><li>edit_file 支持 use_regex 正则替换</li><li>search_files 支持正则表达式搜索(use_regex=true</li><li>browser_screenshot 支持全页面截图+元素截图</li><li>browser_extract 支持CSS选择器提取特定区域</li><li>new browser_wait 等待元素出现或定时等待</li><li><code>run_command</code> 支持三模式切换:自动/需确认/禁用</li><li>危险命令(<code>rm -rf</code><code>mkfs</code>、反弹 shell 等)和系统路径(<code>/etc</code><code>~/.ssh</code> 等)被自动拦截</li><li>工具调用以<strong>可视化卡片</strong>展示状态(pending → running → success/error),在工作空间🔧工具页签中显示</li><li>默认最大 <strong>85 轮</strong>工具调用循环,上下文使用率>80%时自动缩减到3轮</li><li>独立工具自动<strong>并行执行</strong>(16个只读工具加入并行白名单),有依赖关系的工具串行执行</li></ul></div> <div class="help-section"><h4>🔧 Tool Calling(始终开启)</h4><ul><li>所有消息均通过 <strong>Agent Loop 自主调用本地工具</strong>,像一个本地 Agent,无普通聊天模式</li><li><strong>40+1 个工具</strong>,分为 9 类:<ul><li><strong>文件系统</strong>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</li><li><strong>命令执行</strong>1 个):run_command(实时流式输出,支持自动/需确认/禁用三种模式,可配置超时)</li><li><strong>联网搜索</strong>2 个):web_search(支持 SearXNG 元搜索引擎 JSON API / 四引擎 HTML 解析,双模式可切换)/ web_fetch(反爬headers+UA自动切换+自动回退浏览器渲染)</li><li><strong>Git</strong>(1 个):git17 个子操作,push/pull/clone 内置60-120s超时保护)</li><li><strong>浏览器控制</strong>9 个):browser_open / browser_screenshot / browser_evaluate / browser_extract / browser_click / browser_type / browser_scroll / browser_wait / browser_close</li><li><strong>记忆 & 会话</strong>5 个):memory(统一记忆管理,5 个 action/ session_list / session_read / spawn_task / plan_track</li><li><strong>系统工具</strong>6 个):datetime / calculator / random / uuid / json_format / hash</li></ul></li><li>read_file 支持 binary模式读取/base64解码/字节分页/2000行默认+截断hint自动续读</li><li>write_file 支持 base64写二进制文件/mode(overwrite+append)合并append_file功能/10MB</li><li>edit_file 支持 use_regex 正则替换</li><li>search_files 支持正则表达式搜索(use_regex=true</li><li>browser_screenshot 支持全页面截图+元素截图</li><li>browser_extract 支持CSS选择器提取特定区域</li><li>new browser_wait 等待元素出现或定时等待</li><li><code>run_command</code> 支持三模式切换:自动/需确认/禁用</li><li>危险命令(<code>rm -rf</code><code>mkfs</code>、反弹 shell 等)和系统路径(<code>/etc</code><code>~/.ssh</code> 等)被自动拦截</li><li>工具调用以<strong>可视化卡片</strong>展示状态(pending → running → success/error),在工作空间🔧工具页签中显示</li><li>默认最大 <strong>85 轮</strong>工具调用循环,上下文使用率>80%时自动缩减到3轮</li><li>独立工具自动<strong>并行执行</strong>(16个只读工具加入并行白名单),有依赖关系的工具串行执行</li></ul></div>
<div class="help-section"><h4>🧠 Agent 记忆系统</h4><ul><li>设置中开启后,AI 从对话中<strong>自动提取关键信息</strong>(事实/偏好/规则),跨会话持续积累</li><li><strong>增量提取</strong>:长对话中每 20 轮自动触发轻量级记忆提取,不等到对话结束</li><li>对话结束时自动触发完整记忆提取</li><li>新对话时自动检索相关记忆注入上下文,让 AI "记住"你</li><li>点击顶部 🧠 按钮打开记忆面板:搜索、筛选、编辑、删除</li><li><strong>记忆容量上限 500 条</strong>,超限时自动清理低价值条目(规则类型受保护)</li><li><strong>向量语义搜索</strong>:在设置中选择嵌入模型后,支持语义级别记忆检索(更精准)</li><li>未选择嵌入模型时,使用关键词匹配检索记忆</li><li>AI 可通过 memory 工具主动管理记忆</li><li>记忆写入前自动安全扫描(prompt injection / 敏感信息 / 不可见字符检测)</li><li>记忆存储在本地 SQLite,不会上传到任何服务器</li></ul></div> <div class="help-section"><h4>🧠 Agent 记忆系统</h4><ul><li>记忆存储在工作空间 <strong>MEMORY.md</strong> 文件,受严格路径保护,仅 <code>memory</code> 工具可读写</li><li>新对话时自动检索相关记忆注入 AI 上下文,让 AI "记住"你</li><li>对话结束时 AI 自动提取有价值的用户信息保存(多层质量过滤,宁缺毋滥)</li><li><strong>memory 工具</strong>5 个 action):search(关键词搜索)/ add(添加)/ replace(替换)/ remove(删除)/ read_all(读取全部)</li><li>点击顶部 🧠 按钮打开记忆面板:查看、添加、删除记忆条目</li><li><strong>记忆容量上限 500 条</strong>,超限时自动清理低价值条目(规则类型受保护)</li><li>写入前自动安全扫描(prompt injection / 敏感信息 / 不可见字符检测)</li><li>应用启动时自动校验 MEMORY.md 格式,格式错误自动备份重建</li></ul></div>
<div class="help-section"><h4>📋 Plan Mode(计划模式)</h4><ul><li>点击输入框上方 📋 按钮开启 <strong>Plan Mode</strong>(开关式)</li><li>开启后,AI <strong>先生成执行计划</strong>(Markdown 渲染弹窗),用户批准后才开始执行</li><li>计划批准后自动初始化追踪器,每个步骤完成后调用 <code>plan_track</code> 工具标记进度</li><li>系统提示词自动注入当前进度状态,AI 始终知道还剩多少步未完成</li><li><strong>多步骤任务防遗忘</strong>:自动检测用户请求中的动作动词,对比已完成步骤,注入提醒</li><li>关闭 Plan Mode 后恢复正常 Agent Loop 模式</li><li><strong>仅 Plan 模式</strong> 下 plan_track 工具可见,避免污染普通模式的工具列表</li></ul></div> <div class="help-section"><h4>📋 Plan Mode(计划模式)</h4><ul><li>点击输入框上方 📋 按钮开启 <strong>Plan Mode</strong>(开关式)</li><li>开启后,AI <strong>先生成执行计划</strong>(Markdown 渲染弹窗),用户批准后才开始执行</li><li>计划批准后自动初始化追踪器,每个步骤完成后调用 <code>plan_track</code> 工具标记进度</li><li>系统提示词自动注入当前进度状态,AI 始终知道还剩多少步未完成</li><li><strong>多步骤任务防遗忘</strong>:自动检测用户请求中的动作动词,对比已完成步骤,注入提醒</li><li>关闭 Plan Mode 后恢复正常 Agent Loop 模式</li><li><strong>仅 Plan 模式</strong> 下 plan_track 工具可见,避免污染普通模式的工具列表</li></ul></div>
<div class="help-section"><h4>🛡️ 抗幻觉 & 稳定性</h4><ul><li><strong>5 层防御体系</strong>:系统提示词加固 → 中途幻觉检测(16 条规则覆盖全部工具类别)→ 进度锚点 → 完成闸门(6 项检查)→ 钩子校验</li><li><strong>完成闸门</strong>:对话结束前自动审查 AI 回复,检测 4 类关键问题(工具幻觉/注入/文件操作/遗漏),阻断有问题的回复</li><li><strong>16 条幻觉检测规则</strong>:覆盖文件编辑、浏览器操作、Git、记忆、压缩等所有工具类别,防止 AI 声称执行了未调用的工具</li><li><strong>看门狗超时</strong>:设置面板可配置全局超时(默认 30 分钟),AI 卡死或无限循环时自动中止</li><li><strong>中止保护</strong>:所有状态处理器 + 重试循环均检查中止信号,点击 ■ 按钮立即生效</li><li><strong>上下文硬上限</strong>:消息数超过阈值时强制触发压缩,防止 OOM</li></ul></div> <div class="help-section"><h4>🛡️ 抗幻觉 & 稳定性</h4><ul><li><strong>5 层防御体系</strong>:系统提示词加固 → 中途幻觉检测(16 条规则覆盖全部工具类别)→ 进度锚点 → 完成闸门(6 项检查)→ 钩子校验</li><li><strong>完成闸门</strong>:对话结束前自动审查 AI 回复,检测 4 类关键问题(工具幻觉/注入/文件操作/遗漏),阻断有问题的回复</li><li><strong>16 条幻觉检测规则</strong>:覆盖文件编辑、浏览器操作、Git、记忆、压缩等所有工具类别,防止 AI 声称执行了未调用的工具</li><li><strong>看门狗超时</strong>:设置面板可配置全局超时(默认 30 分钟),AI 卡死或无限循环时自动中止</li><li><strong>中止保护</strong>:所有状态处理器 + 重试循环均检查中止信号,点击 ■ 按钮立即生效</li><li><strong>上下文硬上限</strong>:消息数超过阈值时强制触发压缩,防止 OOM</li></ul></div>
<div class="help-section"><h4>🤖 Agent Loop 增强</h4><ul><li><strong>🎬 视频上传</strong>:支持上传 .mp4/.avi/.mov/.mkv/.webm 等视频(≤10MB),自动 1fps 提取帧序列(带时间戳),多模态模型原生理解视频时序关系</li><li><strong>8 状态机</strong>INIT→THINKING→PARSING→EXECUTING→OBSERVING→REFLECTING→COMPRESSING→TERMINATED,每个状态独立的中止检查和处理</li><li><strong>智能上下文压缩</strong>:滑动窗口 + LLM结构化JSON摘要,超过50%上下文窗口自动触发</li><li><strong>流式调用超时保护</strong>:可配置超时(默认300s),Ollama 卡死不再永久阻塞</li><li><strong>HTTP/MCP 超时可配</strong>:设置面板可分别调整 HTTP(默认30s)和 MCP(默认60s)超时</li><li><strong>Final Answer 智能检测</strong>:多模式匹配(Final Answer/最终答案/最终回答/总结),避免漏检或误触</li><li><strong>Token 感知迭代预算</strong>:上下文使用率>80%时自动缩减剩余轮次到3轮</li><li><strong>工具缓存 TTL</strong>:搜索5分钟/网页10分钟/文件30分钟,过期自动刷新</li><li><strong>自动子任务拆解</strong>:检测"同时/分别/以及"等并行关键词,自动 spawn 子代理并行处理</li><li><strong>旧工具结果自动截断</strong>:超过10轮的工具结果自动截断到500字符,控制上下文膨胀</li></ul></div> <div class="help-section"><h4>🤖 Agent Loop 增强</h4><ul><li><strong>🎬 视频上传</strong>:支持上传 .mp4/.avi/.mov/.mkv/.webm 等视频(≤10MB),自动 1fps 提取帧序列(带时间戳),多模态模型原生理解视频时序关系</li><li><strong>8 状态机</strong>INIT→THINKING→PARSING→EXECUTING→OBSERVING→REFLECTING→COMPRESSING→TERMINATED,每个状态独立的中止检查和处理</li><li><strong>智能上下文压缩</strong>:滑动窗口 + LLM结构化JSON摘要,超过50%上下文窗口自动触发</li><li><strong>流式调用超时保护</strong>:可配置超时(默认300s),Ollama 卡死不再永久阻塞</li><li><strong>HTTP/MCP 超时可配</strong>:设置面板可分别调整 HTTP(默认30s)和 MCP(默认60s)超时</li><li><strong>Final Answer 智能检测</strong>:多模式匹配(Final Answer/最终答案/最终回答/总结),避免漏检或误触</li><li><strong>Token 感知迭代预算</strong>:上下文使用率>80%时自动缩减剩余轮次到3轮</li><li><strong>工具缓存 TTL</strong>:搜索5分钟/网页10分钟/文件30分钟,过期自动刷新</li><li><strong>自动子任务拆解</strong>:检测"同时/分别/以及"等并行关键词,自动 spawn 子代理并行处理</li><li><strong>旧工具结果自动截断</strong>:超过10轮的工具结果自动截断到500字符,控制上下文膨胀</li></ul></div>
@@ -468,7 +459,7 @@
<div class="modal-overlay" id="toolsModal" style="display:none;"> <div class="modal-overlay" id="toolsModal" style="display:none;">
<div class="modal"> <div class="modal">
<div class="modal-header"> <div class="modal-header">
<h3>🔧 工具面板(42 个 + 1 Plan 专属)</h3> <h3>🔧 工具面板(40 个 + 1 Plan 专属)</h3>
<button class="icon-btn" id="btnCloseTools"> <button class="icon-btn" id="btnCloseTools">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/> <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
@@ -567,20 +558,8 @@
<div class="tool-card-desc">SearXNG JSON API / 四引擎并行,默认30条</div> <div class="tool-card-desc">SearXNG JSON API / 四引擎并行,默认30条</div>
</div> </div>
<div class="tool-card"> <div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">🧠</span><span class="tool-card-name">memory_search</span><span class="tool-card-badge auto">自动</span></div> <div class="tool-card-header"><span class="tool-card-icon">🧠</span><span class="tool-card-name">memory</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-desc">语义搜索 Agent 记忆,检索相关历史信息</div> <div class="tool-card-desc">统一记忆管理(5 action):search 关键词搜索 / add 添加 / replace 子串替换 / remove 子串删除 / read_all 读取全部。MEMORY.md 受路径保护</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">💾</span><span class="tool-card-name">memory_add</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-desc">添加记忆条目(事实/偏好/规则),跨会话积累</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">🔄</span><span class="tool-card-name">memory_replace</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-desc">替换已有记忆内容(子串匹配 old_text,替换为 new_content</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">🗑️</span><span class="tool-card-name">memory_remove</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-desc">删除记忆条目(子串匹配 old_text)</div>
</div> </div>
<div class="tool-card"> <div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">📋</span><span class="tool-card-name">session_list</span><span class="tool-card-badge auto">自动</span></div> <div class="tool-card-header"><span class="tool-card-icon">📋</span><span class="tool-card-name">session_list</span><span class="tool-card-badge auto">自动</span></div>
+9 -6
View File
@@ -23,7 +23,6 @@ import { initHistoryModal, closeHistoryModal } from './components/history-modal.
import { initMemoryModal } from './components/memory-modal.js'; import { initMemoryModal } from './components/memory-modal.js';
import { initToolsModal, closeToolsModal } from './components/tools-modal.js'; import { initToolsModal, closeToolsModal } from './components/tools-modal.js';
import { initTokenDashboard, closeTokenDashboard } from './components/token-dashboard.js'; import { initTokenDashboard, closeTokenDashboard } from './components/token-dashboard.js';
import { initMemoryManager } from './services/memory-manager.js';
import { setToolEnabled, setRunCommandMode, refreshMCPTools } from './services/tool-registry.js'; import { setToolEnabled, setRunCommandMode, refreshMCPTools } from './services/tool-registry.js';
import { startAllMCPServers } from './services/mcp-client.js'; import { startAllMCPServers } from './services/mcp-client.js';
import { initToolConfirmModal } from './components/tool-confirm-modal.js'; import { initToolConfirmModal } from './components/tool-confirm-modal.js';
@@ -321,7 +320,6 @@ async function init(): Promise<void> {
// ── 防御性重置:确保首次启动不会遗留脏状态 ── // ── 防御性重置:确保首次启动不会遗留脏状态 ──
state.set('toolCallingEnabled', true); state.set('toolCallingEnabled', true);
state.set('runCommandMode', 'confirm'); // 默认需确认 state.set('runCommandMode', 'confirm'); // 默认需确认
state.set('memoryEnabled', true);
state.set('thinkEnabled', false); state.set('thinkEnabled', false);
initLogPanel(); // ← 日志面板最先初始化,确保后续 init 日志能显示 initLogPanel(); // ← 日志面板最先初始化,确保后续 init 日志能显示
@@ -346,9 +344,13 @@ async function init(): Promise<void> {
await checkConnection(); await checkConnection();
await loadModels(); await loadModels();
await initMemoryManager();
await loadSearxngConfig(db); await loadSearxngConfig(db);
// ── MEMORY.md 初始化 ──
import('./services/memory-service.js').then(({ initMemoryFile }) => {
initMemoryFile().catch(() => {});
});
// ── Harness Engineering 系统初始化 ── // ── Harness Engineering 系统初始化 ──
initHarnessHooks(); initHarnessHooks();
initVerificationSystem(); initVerificationSystem();
@@ -435,9 +437,6 @@ async function init(): Promise<void> {
await startAllMCPServers(); await startAllMCPServers();
await refreshMCPTools(); await refreshMCPTools();
// ── Agent 记忆设置 ──
const memoryEnabled = await db.getSetting('memoryEnabled', true);
state.set('memoryEnabled', memoryEnabled);
(document.querySelector('#chatInput') as HTMLElement)?.focus(); (document.querySelector('#chatInput') as HTMLElement)?.focus();
@@ -471,6 +470,10 @@ async function init(): Promise<void> {
setupDesktopIntegration(); setupDesktopIntegration();
initHarnessHooks(); initHarnessHooks();
initVerificationSystem(); initVerificationSystem();
// ── MEMORY.md 初始化(fallback 路径)──
import('./services/memory-service.js').then(({ initMemoryFile }) => {
initMemoryFile().catch(() => {});
});
checkConnection().then(loadModels); checkConnection().then(loadModels);
} }
} }
+4 -4
View File
@@ -11,7 +11,7 @@
- **联网搜索与抓取**:搜索网页获取摘要,然后对相关结果逐条抓取完整内容进行深度分析 - **联网搜索与抓取**:搜索网页获取摘要,然后对相关结果逐条抓取完整内容进行深度分析
- **浏览器控制**:打开网页、截图(视口/全页/指定元素)、执行 JavaScript、提取页面文本、点击元素、输入文本、滚动页面 - **浏览器控制**:打开网页、截图(视口/全页/指定元素)、执行 JavaScript、提取页面文本、点击元素、输入文本、滚动页面
- **Git 操作**:完整的版本控制——查看状态、日志、差异,暂存、提交、推送、拉取,分支管理、合并、标签、远程仓库操作 - **Git 操作**:完整的版本控制——查看状态、日志、差异,暂存、提交、推送、拉取,分支管理、合并、标签、远程仓库操作
- **记忆管理**:跨会话记住用户的重要信息(事实、偏好、规则),搜索历史记忆,添加/修改/删除记忆条目 - **记忆管理**:跨会话记住用户的重要信息(事实、偏好、规则),搜索历史记忆,添加/修改/删除记忆条目。记忆存储在工作空间 MEMORY.md 文件中。
- **会话管理**:列出和读取历史会话记录,跨会话引用之前的对话内容 - **会话管理**:列出和读取历史会话记录,跨会话引用之前的对话内容
- **子代理委派**:将独立子任务委派给子代理并行执行,完成后汇总结果 - **子代理委派**:将独立子任务委派给子代理并行执行,完成后汇总结果
- **系统工具**:获取精确时间、数学计算、随机生成、UUID 生成、JSON 格式化、哈希计算 - **系统工具**:获取精确时间、数学计算、随机生成、UUID 生成、JSON 格式化、哈希计算
@@ -71,9 +71,9 @@ git status(查看当前状态)
### 记忆管理 ### 记忆管理
``` ```
memory_search(查找相关记忆) memory search(查找相关记忆)
→ 如果发现过时或错误 → memory_replace 或 memory_remove → 如果发现过时或错误 → memory replace 或 memory remove
→ 如果学到新信息 → memory_add(类型:fact 事实 / preference 偏好 / rule 规则) → 如果学到新信息 → memory add(类型:fact 事实 / preference 偏好 / rule 规则)
``` ```
## 重要约束 ## 重要约束
+24 -27
View File
@@ -15,7 +15,8 @@ import {
formatPlanStatus, formatPlanStatus,
clearPlanTracker, clearPlanTracker,
} from './tool-registry.js'; } from './tool-registry.js';
import { searchMemories, buildMemoryContext, markMemoryUsed, isMemoryEnabled } from './memory-manager.js'; import { search, formatMemoryContext, loadAllEntries } from './memory-service.js';
import { showToast } from '../components/toast.js'; import { showToast } from '../components/toast.js';
import { logInfo, logWarn, logSuccess, logError, logToolStart, logToolResult, logAgentLoop, logModelResponse, logStreamProgress, resetStreamProgress } from './log-service.js'; import { logInfo, logWarn, logSuccess, logError, logToolStart, logToolResult, logAgentLoop, logModelResponse, logStreamProgress, resetStreamProgress } from './log-service.js';
import { getWorkspaceDirPath } from '../components/workspace-panel.js'; import { getWorkspaceDirPath } from '../components/workspace-panel.js';
@@ -126,7 +127,7 @@ const VALID_TOOL_NAMES = new Set([
'delete_file', 'run_command', 'move_file', 'copy_file', 'web_fetch', 'web_search', 'delete_file', 'run_command', 'move_file', 'copy_file', 'web_fetch', 'web_search',
'edit_file', 'get_file_info', 'tree', 'download_file', 'diff_files', 'edit_file', 'get_file_info', 'tree', 'download_file', 'diff_files',
'replace_in_files', 'read_multiple_files', 'git', 'compress', 'replace_in_files', 'read_multiple_files', 'git', 'compress',
'memory_search', 'memory_add', 'memory_replace', 'memory_remove', 'session_list', 'session_read', 'memory', 'session_list', 'session_read',
'datetime', 'calculator', 'datetime', 'calculator',
'random', 'uuid', 'json_format', 'hash' 'random', 'uuid', 'json_format', 'hash'
]); ]);
@@ -518,12 +519,13 @@ async function handleInit(
} }
// 注入记忆上下文 // 注入记忆上下文
if (isMemoryEnabled() && userContent) { if (userContent) {
const relevantMemories = searchMemories(userContent, 6); try {
const relevantMemories = await search(userContent, 6);
if (relevantMemories.length > 0) { if (relevantMemories.length > 0) {
systemPromptParts.push(buildMemoryContext(relevantMemories)); systemPromptParts.push(formatMemoryContext(relevantMemories));
for (const m of relevantMemories) { await markMemoryUsed(m.id); }
} }
} catch { /* 记忆搜索失败不影响主流程 */ }
} }
// 注入工作空间上下文 // 注入工作空间上下文
@@ -677,7 +679,7 @@ Shell: ${osInfo.shell}
} }
} }
logInfo(`ReAct Agent Loop 启动: ${model}`, `工具: ${useTools ? '开启' : '关闭'}, 记忆: ${isMemoryEnabled() ? '开启' : '关闭'}, 模式: ${ctx.mode}, tokens≈${estimateTokens(ctx.messages.map(m => m.content || '').join(''))}`); logInfo(`ReAct Agent Loop 启动: ${model}`, `工具: ${useTools ? '开启' : '关闭'}, 模式: ${ctx.mode}, tokens≈${estimateTokens(ctx.messages.map(m => m.content || '').join(''))}`);
// ── Plan Mode: 如果是 plan 模式,先注入计划提示 ── // ── Plan Mode: 如果是 plan 模式,先注入计划提示 ──
if (ctx.mode === 'plan' && ctx.loopCount === 0) { if (ctx.mode === 'plan' && ctx.loopCount === 0) {
@@ -1444,18 +1446,6 @@ async function handleObserving(
.filter(r => r.status === 'success') .filter(r => r.status === 'success')
.map(r => getToolCacheKey(r.name, r.arguments)); .map(r => getToolCacheKey(r.name, r.arguments));
// 增量记忆提取 — 每 20 轮
if (isMemoryEnabled() && ctx.loopCount > 1 && ctx.loopCount % 20 === 0 && ctx.messages.length >= 10) {
import('./memory-manager.js').then(({ extractMemoriesFromConversation }) => {
const recentMsgs = ctx.messages.slice(-30).filter(m => m.role === 'user' || m.role === 'assistant');
extractMemoriesFromConversation(
recentMsgs.map(m => ({ role: m.role, content: m.content })),
currentSession?.title
).then(() => logInfo('增量记忆提取完成', `${ctx.loopCount}`))
.catch(() => {});
}).catch(() => {});
}
// 保存本轮工具调用 // 保存本轮工具调用
ctx.prevToolCalls = [...ctx.toolCalls]; ctx.prevToolCalls = [...ctx.toolCalls];
@@ -1670,14 +1660,21 @@ async function handleReflecting(
logInfo('模型停止工具调用 → ReAct Agent Loop 结束'); logInfo('模型停止工具调用 → ReAct Agent Loop 结束');
clearPlanTracker(); clearPlanTracker();
if (isMemoryEnabled() && ctx.messages.length >= 10) {
try { // ── 自动记忆提取(异步,不阻塞用户看到回复)──
const { extractMemoriesFromConversation } = await import('./memory-manager.js'); const abortController = state.get<AbortController | null>(KEYS.ABORT_CONTROLLER);
await extractMemoriesFromConversation( if (!abortController?.signal.aborted) {
ctx.messages.filter(m => m.role === 'user' || m.role === 'assistant').map(m => ({ role: m.role, content: m.content })), const _api = state.get<OllamaAPI>(KEYS.API);
currentSession?.title const _model = state.get<string>('_defaultModel', '');
); const msgsForMemory = ctx.messages
} catch { /* 不阻塞 */ } .filter(m => m.role === 'user' || m.role === 'assistant')
.map(m => ({ role: m.role, content: m.content || '' }));
// 使用 setTimeout 延迟执行,确保 onDone 先触发,用户先看到回复
setTimeout(() => {
import('./memory-service.js').then(({ extractAndSaveMemories }) => {
extractAndSaveMemories(msgsForMemory, _api, _model).catch(() => {});
}).catch(() => {});
}, 500);
} }
callbacks.onDone(ctx.content || '(模型未返回内容)', ctx.allToolRecords.length > 0 ? ctx.allToolRecords : undefined, makeStats(ctx)); callbacks.onDone(ctx.content || '(模型未返回内容)', ctx.allToolRecords.length > 0 ? ctx.allToolRecords : undefined, makeStats(ctx));
+2 -2
View File
@@ -138,7 +138,7 @@ const toolHallucinationCheck: CompletionCheck = {
const calledTools = new Set(ctx.allToolRecords.map(r => r.name)); const calledTools = new Set(ctx.allToolRecords.map(r => r.name));
const content = ctx.content; const content = ctx.content;
// ── 统一的"声称 → 必需工具"映射表(覆盖全部 42 个工具)── // ── 统一的"声称 → 必需工具"映射表(覆盖全部 40 个工具)──
const CLAIM_RULES: Array<{ patterns: RegExp[]; requiredTools: string[]; label: string }> = [ const CLAIM_RULES: Array<{ patterns: RegExp[]; requiredTools: string[]; label: string }> = [
// 文件写入 // 文件写入
{ patterns: [/已写入文件/, /已创建文件/, /文件已生成/, /已保存到/, /已成功创建/, /成功写入/, { patterns: [/已写入文件/, /已创建文件/, /文件已生成/, /已保存到/, /已成功创建/, /成功写入/,
@@ -191,7 +191,7 @@ const toolHallucinationCheck: CompletionCheck = {
requiredTools: ['get_file_info'], label: '获取文件信息' }, requiredTools: ['get_file_info'], label: '获取文件信息' },
// 内存/记忆操作 // 内存/记忆操作
{ patterns: [/已记住/, /已添加记忆/, /记忆已保存/, /已更新记忆/, /已删除记忆/], { patterns: [/已记住/, /已添加记忆/, /记忆已保存/, /已更新记忆/, /已删除记忆/],
requiredTools: ['memory_add', 'memory_replace', 'memory_remove'], label: '记忆操作' }, requiredTools: ['memory'], label: '记忆操作' },
// 会话操作 // 会话操作
{ patterns: [/历史会话.*显示/, /已读取.*会话/, /会话.*内容.*如下/], { patterns: [/历史会话.*显示/, /已读取.*会话/, /会话.*内容.*如下/],
requiredTools: ['session_list', 'session_read'], label: '会话操作' }, requiredTools: ['session_list', 'session_read'], label: '会话操作' },
-751
View File
@@ -1,751 +0,0 @@
/**
* MemoryManager - Agent
*
*
*
* - fact: 用户告诉我的事实
* - preference: 用户偏好
* - rule: 应遵守的规则
*/
import { state, KEYS } from '../state/state.js';
import { generateId } from '../utils/utils.js';
import {
initMemoryVectorStore, getOrCreateMemoryCollection,
embedMemoryEntry, addMemoryVector, updateMemoryVector,
deleteMemoryVector, searchMemoriesByVector, reindexAllMemories,
getMemoryCollectionId
} from './vector-memory.js';
import { logMemory, logDebug, logWarn, logInfo } from './log-service.js';
import type { MemoryEntry, MemorySearchResult, MemoryExtractionResult } from '../types.js';
import type { ChatDB } from '../db/chat-db.js';
import type { OllamaAPI } from '../api/ollama.js';
const TYPE_ICONS: Record<string, string> = {
fact: '📌',
preference: '⚙️',
rule: '📏'
};
const TYPE_NAMES: Record<string, string> = {
fact: '事实',
preference: '偏好',
rule: '规则'
};
let memoryCache: MemoryEntry[] = [];
let memoryEnabled = true;
let embeddingModel = '';
// ── 初始化 ──
export async function initMemoryManager(): Promise<void> {
const db = state.get<ChatDB | null>(KEYS.DB);
if (!db) return;
memoryEnabled = await db.getSetting('memoryEnabled', true);
state.set('memoryEnabled', memoryEnabled);
// 加载嵌入模型设置
embeddingModel = await db.getSetting('embeddingModel', '');
state.set('embeddingModel', embeddingModel);
memoryCache = await db.getAllMemories();
state.set('memoryEntries', memoryCache);
// 如果有嵌入模型,初始化向量存储
if (embeddingModel) {
try {
await initMemoryVectorStore();
logMemory('向量存储已初始化');
} catch (err) {
logWarn('向量存储初始化失败', (err as Error).message);
}
}
logMemory(`初始化完成, 加载 ${memoryCache.length}${embeddingModel ? ', 向量记忆已启用' : ', 仅关键词模式'}`);
}
// ── 嵌入模型管理 ──
export function getEmbeddingModel(): string {
return embeddingModel;
}
export async function setEmbeddingModel(model: string): Promise<void> {
const oldModel = embeddingModel;
embeddingModel = model;
state.set('embeddingModel', model);
const db = state.get<ChatDB | null>(KEYS.DB);
if (db) await db.saveSetting('embeddingModel', model);
if (model && model !== oldModel) {
// 嵌入模型变化,重新索引所有记忆
logMemory(`嵌入模型变更: ${oldModel || '(无)'}${model}`);
await reindexMemories();
}
}
export function isVectorMemoryEnabled(): boolean {
return !!embeddingModel;
}
// ── 重新索引所有记忆 ──
export async function reindexMemories(): Promise<void> {
if (!embeddingModel || memoryCache.length === 0) return;
try {
await initMemoryVectorStore();
const { id: colId } = await getOrCreateMemoryCollection(embeddingModel);
await reindexAllMemories(memoryCache, embeddingModel, colId, (done, total) => {
logMemory(`重新索引进度: ${done}/${total}`);
});
logMemory('向量索引重建完成');
} catch (err) {
logWarn('向量索引重建失败', (err as Error).message);
}
}
// ── 基础管理 ──
export function isMemoryEnabled(): boolean {
return memoryEnabled;
}
export function setMemoryEnabled(enabled: boolean): void {
memoryEnabled = enabled;
state.set('memoryEnabled', enabled);
const db = state.get<ChatDB | null>(KEYS.DB);
if (db) db.saveSetting('memoryEnabled', enabled);
}
export function getMemoryCache(): MemoryEntry[] {
return memoryCache;
}
export function getTypeIcon(type: string): string {
return TYPE_ICONS[type] || '📌';
}
export function getTypeName(type: string): string {
return TYPE_NAMES[type] || type;
}
// ── 记忆检索(向量优先,关键词补充)──
export function searchMemories(query: string, limit = 8): MemorySearchResult[] {
if (!memoryEnabled || memoryCache.length === 0) return [];
// 始终执行关键词搜索(即时结果)
const keywordResults = searchMemoriesByKeyword(query, limit);
// 如果启用了向量记忆,异步触发向量搜索(结果在下次调用时更新)
if (embeddingModel) {
triggerVectorSearch(query, limit);
}
return keywordResults;
}
// 向量搜索异步缓存
const vectorSearchCache = new Map<string, MemorySearchResult[]>();
let vectorSearchTimer: ReturnType<typeof setTimeout> | null = null;
function triggerVectorSearch(query: string, limit: number): void {
if (vectorSearchTimer) clearTimeout(vectorSearchTimer);
vectorSearchTimer = setTimeout(async () => {
try {
const colId = getMemoryCollectionId();
if (!colId) return;
const results = await searchMemoriesByVector(query, colId, limit);
const memoryResults: MemorySearchResult[] = results.map(r => {
const entry = memoryCache.find(e => e.id === r.docId);
if (!entry) return null;
return { ...entry, score: r.score };
}).filter(Boolean) as MemorySearchResult[];
vectorSearchCache.set(query, memoryResults);
} catch (err) {
logWarn('向量搜索失败', (err as Error).message);
}
}, 100);
}
// 关键词搜索
function searchMemoriesByKeyword(query: string, limit: number): MemorySearchResult[] {
const queryLower = query.toLowerCase();
const queryWords = queryLower.split(/[\s,,。!?、;:""''()\[\]{}<>`~@#$%^&*+=|\\/.]+/).filter(w => w.length > 1);
// rule 和 preference 类型的记忆全局生效,始终注入
const alwaysInclude: MemorySearchResult[] = [];
const scored: MemorySearchResult[] = [];
for (const entry of memoryCache) {
if (entry.type === 'rule' || entry.type === 'preference') {
// 规则和偏好:始终包含,按重要性排序
alwaysInclude.push({ ...entry, score: 100 + entry.importance });
continue;
}
// fact 类型:按关键词匹配
let score = 0;
const contentLower = entry.content.toLowerCase();
const tagsLower = entry.tags.map(t => t.toLowerCase());
// 完全匹配内容
if (contentLower.includes(queryLower)) score += 50;
// 标签匹配
for (const tag of tagsLower) {
if (queryLower.includes(tag) || tag.includes(queryLower)) score += 30;
for (const word of queryWords) {
if (tag.includes(word)) score += 15;
}
}
// 关键词匹配
for (const word of queryWords) {
if (contentLower.includes(word)) score += 10;
}
// 重要性加权
score *= (0.5 + entry.importance / 20);
// 最近使用加权(7 天内使用过 +20%)
const daysSinceUse = (Date.now() - entry.lastUsedAt) / (1000 * 60 * 60 * 24);
if (daysSinceUse < 7) score *= 1.2;
// 使用频率加权
score *= (1 + Math.min(entry.useCount, 10) / 50);
if (score > 0) {
scored.push({ ...entry, score });
}
}
// 合并:全局规则/偏好 + 关键词匹配的事实,去重后按分数排序
const seen = new Set<string>();
const merged: MemorySearchResult[] = [];
for (const item of [...alwaysInclude, ...scored.sort((a, b) => b.score - a.score)]) {
if (!seen.has(item.id)) {
seen.add(item.id);
merged.push(item);
}
}
return merged.slice(0, limit);
}
// ── 记忆添加 ──
export async function addMemory(data: {
type: MemoryEntry['type'];
content: string;
importance?: number;
tags?: string[];
source?: string;
sessionId?: string;
}): Promise<MemoryEntry> {
const db = state.get<ChatDB | null>(KEYS.DB);
// 自动提取来源禁止添加 rule 类型
if (data.type === 'rule' && data.source === '自动提取') {
logWarn('记忆提取拦截', '自动提取不允许创建规则类型,规则只能由用户手动添加');
throw new Error('规则类型记忆只能由用户手动添加');
}
// 安全扫描:检测 prompt injection 和敏感信息
const securityCheck = scanMemorySecurity(data.content);
if (!securityCheck.safe) {
logWarn('记忆安全扫描拦截', securityCheck.reason);
throw new Error(`记忆内容被安全规则拦截: ${securityCheck.reason}`);
}
// v5.1.2 记忆容量限制:超过上限自动清理低价值条目
const MAX_MEMORIES = 500;
if (memoryCache.length >= MAX_MEMORIES) {
const evicted = autoCleanMemories();
if (evicted > 0) {
logInfo('记忆容量清理', `已清理 ${evicted} 条低价值记忆(上限 ${MAX_MEMORIES}`);
}
}
// 检查重复(内容相似度 > 80% 或前缀匹配则跳过)
const existing = memoryCache.find(e => {
if (e.type !== data.type) return false;
// v5.1.3 前缀匹配:前 50 字符完全相同视为重复
const prefixLen = Math.min(50, data.content.length, e.content.length);
if (prefixLen > 20 && data.content.slice(0, prefixLen) === e.content.slice(0, prefixLen)) {
return true;
}
const similarity = simpleSimilarity(e.content, data.content);
return similarity > 0.8;
});
if (existing) return existing;
const entry: MemoryEntry = {
id: `mem_${generateId()}`,
type: data.type,
content: data.content.trim(),
importance: data.importance ?? 5,
tags: data.tags || extractTags(data.content),
source: data.source,
sessionId: data.sessionId,
createdAt: Date.now(),
updatedAt: Date.now(),
lastUsedAt: Date.now(),
useCount: 0
};
memoryCache.push(entry);
state.set('memoryEntries', [...memoryCache]);
if (db) await db.saveMemory(entry);
logMemory(`新增: ${data.type}`, entry.content.slice(0, 60));
// 向量存储
if (embeddingModel) {
try {
await initMemoryVectorStore();
const colId = getMemoryCollectionId() || (await getOrCreateMemoryCollection(embeddingModel)).id;
const embedding = await embedMemoryEntry(entry, embeddingModel);
entry.embedding = embedding;
await addMemoryVector(entry, embedding, colId);
if (db) await db.saveMemory(entry); // 保存包含 embedding 的版本
} catch (err) {
const errMsg = (err as Error).message;
logWarn('向量存储失败', `记忆 "${entry.content.slice(0, 40)}" (${entry.id}): ${errMsg}`);
logDebug('向量存储失败详情', `模型: ${embeddingModel}, 类型: ${entry.type}, 错误: ${(err as Error).stack?.split('\n')[0] || errMsg}`);
}
}
return entry;
}
// ── 容量管理 ──
/**
*
* 20%
* = importance * 2 + useCount * 3 + recencyBonus 7 使 +5
* rule
* @returns
*/
function autoCleanMemories(): number {
const db = state.get<ChatDB | null>(KEYS.DB);
const now = Date.now();
const SEVEN_DAYS = 7 * 24 * 3600 * 1000;
const NINETY_DAYS = 90 * 24 * 3600 * 1000;
// v5.1.3 记忆过期衰减:90 天未使用的记忆自动降级 importance
for (const entry of memoryCache) {
if (entry.type === 'rule') continue; // rule 受保护
const unusedDays = now - entry.lastUsedAt;
if (unusedDays > NINETY_DAYS && entry.importance > 1) {
entry.importance = Math.max(1, entry.importance - 2);
if (db) db.saveMemory(entry);
}
}
// 计算综合评分
const scored = memoryCache
.map((entry) => {
const recencyBonus = (now - entry.lastUsedAt) < SEVEN_DAYS ? 5 : 0;
const agePenalty = (now - entry.createdAt) > NINETY_DAYS ? 3 : 0;
const score = entry.importance * 2 + entry.useCount * 3 + recencyBonus - agePenalty;
return { entry, score };
})
// rule 类型受保护
.filter(s => s.entry.type !== 'rule')
.sort((a, b) => a.score - b.score);
const evictCount = Math.max(1, Math.floor(memoryCache.length * 0.2));
const toEvict = scored.slice(0, evictCount);
// 从缓存和数据库中移除
const evictIds = new Set(toEvict.map(s => s.entry.id));
for (const id of evictIds) {
if (db) db.deleteMemory(id);
}
memoryCache = memoryCache.filter(e => !evictIds.has(e.id));
state.set('memoryEntries', [...memoryCache]);
return evictIds.size;
}
// ── 安全扫描 ──
/** 记忆内容安全扫描 */
function scanMemorySecurity(content: string): { safe: boolean; reason: string } {
// Prompt injection 模式
const injectionPatterns = [
/ignore\s+(all\s+)?previous/i,
/forget\s+(all\s+)?instructions/i,
/you\s+are\s+now\s+a/i,
/new\s+system\s*prompt/i,
/override\s+(your|the)\s+/i,
/disregard\s+(all|any|previous)/i,
];
for (const p of injectionPatterns) {
if (p.test(content)) return { safe: false, reason: '疑似 prompt injection 攻击' };
}
// 敏感信息模式
const secretPatterns = [
/-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----/,
/sk-[a-zA-Z0-9]{20,}/,
/ghp_[a-zA-Z0-9]{36}/,
/AKIA[A-Z0-9]{16}/,
/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/, // 信用卡号
];
for (const p of secretPatterns) {
if (p.test(content)) return { safe: false, reason: '疑似包含敏感信息(密钥/密码/信用卡号)' };
}
// 不可见字符
if (/[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/.test(content)) {
return { safe: false, reason: '包含不可见 Unicode 字符' };
}
return { safe: true, reason: '' };
}
// ── 记忆替换(根据 old_text 子串匹配)──
export async function replaceMemoryByContent(
target: 'memory' | 'user',
oldText: string,
newContent: string
): Promise<{ success: boolean; message: string }> {
if (!oldText || oldText.length < 2) {
return { success: false, message: 'old_text 不能为空且至少 2 个字符' };
}
if (!newContent || newContent.length < 2) {
return { success: false, message: 'new_content 不能为空且至少 2 个字符' };
}
// 安全扫描
const securityCheck = scanMemorySecurity(newContent);
if (!securityCheck.safe) {
return { success: false, message: `新内容被安全规则拦截: ${securityCheck.reason}` };
}
// 匹配
const matches = memoryCache.filter(e => {
if (target === 'user') {
return (e.type === 'preference' || e.type === 'fact') && e.content.includes(oldText);
}
return e.content.includes(oldText);
});
if (matches.length === 0) {
return { success: false, message: `未找到包含 "${oldText.slice(0, 50)}" 的记忆` };
}
if (matches.length > 1) {
return { success: false, message: `匹配到 ${matches.length} 条记忆,请使用更精确的 old_text` };
}
await updateMemory(matches[0].id, { content: newContent.trim() });
logMemory('替换记忆', `${matches[0].id}: ${oldText.slice(0, 30)}${newContent.slice(0, 30)}`);
return { success: true, message: `已替换记忆: ${matches[0].id}` };
}
// ── 记忆删除(根据 old_text 子串匹配)──
export async function removeMemoryByContent(
target: 'memory' | 'user',
oldText: string
): Promise<{ success: boolean; message: string }> {
if (!oldText || oldText.length < 2) {
return { success: false, message: 'old_text 不能为空且至少 2 个字符' };
}
const matches = memoryCache.filter(e => {
if (target === 'user') {
return (e.type === 'preference' || e.type === 'fact') && e.content.includes(oldText);
}
return e.content.includes(oldText);
});
if (matches.length === 0) {
return { success: false, message: `未找到包含 "${oldText.slice(0, 50)}" 的记忆` };
}
if (matches.length > 1) {
return { success: false, message: `匹配到 ${matches.length} 条记忆,请使用更精确的 old_text` };
}
await deleteMemory(matches[0].id);
logMemory('删除记忆', `${matches[0].id}: ${oldText.slice(0, 50)}`);
return { success: true, message: `已删除记忆: ${matches[0].id}` };
}
// ── 记忆更新 ──
export async function updateMemory(id: string, updates: Partial<MemoryEntry>): Promise<void> {
const idx = memoryCache.findIndex(e => e.id === id);
if (idx === -1) return;
memoryCache[idx] = { ...memoryCache[idx], ...updates, updatedAt: Date.now() };
state.set('memoryEntries', [...memoryCache]);
const db = state.get<ChatDB | null>(KEYS.DB);
if (db) await db.saveMemory(memoryCache[idx]);
// 更新向量
if (embeddingModel && (updates.content || updates.type || updates.tags)) {
try {
const colId = getMemoryCollectionId();
if (colId) {
const embedding = await embedMemoryEntry(memoryCache[idx], embeddingModel);
memoryCache[idx].embedding = embedding;
await updateMemoryVector(memoryCache[idx], embedding, colId);
if (db) await db.saveMemory(memoryCache[idx]);
}
} catch (err) {
logWarn('向量更新失败', (err as Error).message);
}
}
}
// ── 记忆删除 ──
export async function deleteMemory(id: string): Promise<void> {
memoryCache = memoryCache.filter(e => e.id !== id);
state.set('memoryEntries', [...memoryCache]);
const db = state.get<ChatDB | null>(KEYS.DB);
if (db) await db.deleteMemory(id);
// 删除向量
if (embeddingModel) {
try {
const colId = getMemoryCollectionId();
if (colId) await deleteMemoryVector(id, colId);
} catch (err) {
logWarn('向量删除失败', (err as Error).message);
}
}
logMemory(`删除`, id);
}
// ── 清空所有记忆 ──
export async function clearAllMemories(): Promise<void> {
memoryCache = [];
state.set('memoryEntries', []);
const db = state.get<ChatDB | null>(KEYS.DB);
if (db) await db.clearAllMemories();
// 清空向量集合
if (embeddingModel) {
try {
await initMemoryVectorStore();
const { id: colId } = await getOrCreateMemoryCollection(embeddingModel);
const { getMemoryVectorStore } = await import('./vector-memory.js');
const vs = getMemoryVectorStore();
if (vs) {
await vs.deleteCollection(colId);
// 重新创建空集合
const { setMemoryCollectionId } = await import('./vector-memory.js');
setMemoryCollectionId(null);
await getOrCreateMemoryCollection(embeddingModel);
}
} catch (err) {
logWarn('向量集合清空失败', (err as Error).message);
}
}
}
// ── 标记记忆被使用 ──
export async function markMemoryUsed(id: string): Promise<void> {
const entry = memoryCache.find(e => e.id === id);
if (!entry) return;
entry.useCount++;
entry.lastUsedAt = Date.now();
const db = state.get<ChatDB | null>(KEYS.DB);
if (db) await db.saveMemory(entry);
}
// ── 构建记忆上下文(注入 system prompt)──
export function buildMemoryContext(memories: MemorySearchResult[]): string {
if (memories.length === 0) return '';
const grouped: Record<string, MemorySearchResult[]> = {};
for (const m of memories) {
if (!grouped[m.type]) grouped[m.type] = [];
grouped[m.type].push(m);
}
let context = '';
// 规则类:强制约束,放在最前面
if (grouped['rule']) {
context += '【必须严格遵守的规则】\n以下规则是用户明确要求的,必须无条件执行:\n';
for (const item of grouped['rule']) {
context += ` - ${item.content}\n`;
}
context += '\n';
delete grouped['rule'];
}
// 偏好类:用户偏好
if (grouped['preference']) {
context += '【用户偏好】\n在回答时请遵循以下偏好:\n';
for (const item of grouped['preference']) {
context += ` - ${item.content}\n`;
}
context += '\n';
delete grouped['preference'];
}
// 事实类:参考信息
if (grouped['fact']) {
context += '【关于用户的参考信息】\n以下信息供参考,与当前对话无关时可忽略:\n';
for (const item of grouped['fact']) {
context += ` - ${item.content}\n`;
}
context += '\n';
}
context += '请自然地利用以上信息,不要生硬地列举。';
return context;
}
// ── 自动提取记忆(会话结束时调用)──
export async function extractMemoriesFromConversation(
messages: Array<{ role: string; content: string }>,
sessionTitle?: string
): Promise<number> {
if (!memoryEnabled) return 0;
if (messages.length < 3) return 0;
const api = state.get<OllamaAPI>(KEYS.API);
const model = state.get<string>('_defaultModel', '');
if (!api || !model) return 0;
const recentMessages = messages.slice(-20);
const conversationText = recentMessages
.filter(m => m.role === 'user' || m.role === 'assistant')
.map(m => `[${m.role === 'user' ? '用户' : 'AI'}]: ${m.content}`)
.join('\n\n');
const extractPrompt = `你是一个记忆提取系统。请分析以下对话,提取值得长期记住的关键信息。
${conversationText.slice(0, 4000)}
JSON
\`\`\`json
{
"entries": [
{
"type": "fact",
"content": "用户正在开发一个 Electron 桌面应用",
"importance": 7,
"tags": ["项目", "Electron", "开发"]
}
]
}
\`\`\`
- fact: 关于用户的具体事实
- preference: 用户明确表达的偏好
- rule:
1. ****
2. ****
3. 50
4. importance 1-10 7+
5. tags 2-5
6. 3
****
- "帮我写个函数"
- AI
- "用户喜欢编程""用户是开发者"
-
-
-
{"entries": []}`;
try {
const response = await api.chat({
model,
messages: [{ role: 'user', content: extractPrompt }],
think: false,
options: { num_ctx: 8192, temperature: 0.1 }
} as any);
const content = (response as { message?: { content?: string } })?.message?.content || '';
const jsonMatch = content.match(new RegExp('```json\\s*([\\s\\S]*?)\\s*```')) || content.match(/\{[\s\S]*"entries"[\s\S]*\}/);
if (!jsonMatch) return 0;
const parsed: MemoryExtractionResult = JSON.parse(jsonMatch[1] || jsonMatch[0]);
if (!parsed.entries?.length) return 0;
let count = 0;
const currentSession = state.get(KEYS.CURRENT_SESSION);
for (const entry of parsed.entries) {
if (!entry.content || entry.content.length < 5) continue;
const validType = entry.type === 'fact' || entry.type === 'preference' ? entry.type : 'fact';
// 自动提取仅允许 fact 和 preference
if (entry.type === 'rule') {
logDebug('跳过自动提取的规则类型', entry.content.slice(0, 40));
continue;
}
await addMemory({
type: validType,
content: entry.content,
importance: Math.min(10, Math.max(1, entry.importance || 5)),
tags: entry.tags || [],
source: '自动提取',
sessionId: (currentSession as any)?.id
});
count++;
}
if (count > 0) logMemory(`自动提取 ${count}`);
return count;
} catch (err) {
logWarn('记忆自动提取失败', (err as Error).message);
return 0;
}
}
// ── 工具函数 ──
function extractTags(text: string): string[] {
const words = text
.replace(/[^\w\u4e00-\u9fff\s]/g, ' ')
.split(/\s+/)
.filter(w => w.length > 1);
return [...new Set(words)].slice(0, 5);
}
function simpleSimilarity(a: string, b: string): number {
const aWords = new Set(a.toLowerCase().split(/\s+/));
const bWords = new Set(b.toLowerCase().split(/\s+/));
let intersection = 0;
for (const w of aWords) {
if (bWords.has(w)) intersection++;
}
const union = aWords.size + bWords.size - intersection;
return union === 0 ? 0 : intersection / union;
}
+809
View File
@@ -0,0 +1,809 @@
/**
* Memory Service MEMORY.md
*
* MEMORY.md :
* # METONA MEMORY
* > memory
*
* ## fact | id: mem_YYYYMMDD_NNN | importance: 1-10 | tags: tag1, tag2
*
*
* ## preference | id: mem_YYYYMMDD_NNN | importance: 1-10 | tags: tag1, tag2
*
*
* ## rule | id: mem_YYYYMMDD_NNN | importance: 1-10 | tags: tag1, tag2
*
*/
import { logInfo, logWarn, logDebug, logMemory } from './log-service.js';
// ═══════════════════════════════════════════════════════════════
// 类型定义
// ═══════════════════════════════════════════════════════════════
export type MemoryType = 'fact' | 'preference' | 'rule';
export interface MemoryEntry {
id: string;
type: MemoryType;
content: string;
importance: number; // 1-10
tags: string[];
}
export interface MemorySearchResult extends MemoryEntry {
score: number;
}
// ═══════════════════════════════════════════════════════════════
// 常量
// ═══════════════════════════════════════════════════════════════
const MEMORY_FILE_NAME = 'MEMORY.md';
const MAX_ENTRIES = 500;
/** 文件头部 */
const FILE_HEADER = `# METONA MEMORY
> memory
> : ## [] | id: [ID] | importance: [1-10] | tags: [tag1, tag2, ...]
> ##
`;
/** 条目元数据正则: ## [类型] | id: [ID] | importance: [1-10] | tags: [tag1, tag2] */
const ENTRY_HEADER_RE = /^##\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;
const VALID_TYPES: MemoryType[] = ['fact', 'preference', 'rule'];
// ═══════════════════════════════════════════════════════════════
// 安全扫描
// ═══════════════════════════════════════════════════════════════
function scanMemorySecurity(content: string): { safe: boolean; reason: string } {
const injectionPatterns = [
/ignore\s+(all\s+)?previous/i,
/forget\s+(all\s+)?instructions/i,
/you\s+are\s+now\s+a/i,
/new\s+system\s*prompt/i,
/override\s+(your|the)\s+/i,
/disregard\s+(all|any|previous)/i,
];
for (const p of injectionPatterns) {
if (p.test(content)) return { safe: false, reason: '疑似 prompt injection 攻击' };
}
const secretPatterns = [
/-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----/,
/sk-[a-zA-Z0-9]{20,}/,
/ghp_[a-zA-Z0-9]{36}/,
/AKIA[A-Z0-9]{16}/,
/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/,
];
for (const p of secretPatterns) {
if (p.test(content)) return { safe: false, reason: '疑似包含敏感信息(密钥/密码/信用卡号)' };
}
if (/[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/.test(content)) {
return { safe: false, reason: '包含不可见 Unicode 字符' };
}
return { safe: true, reason: '' };
}
// ═══════════════════════════════════════════════════════════════
// IPC 通信 — 通过专用通道访问 MEMORY.md
// ═══════════════════════════════════════════════════════════════
function getBridge() {
const bridge = window.metonaDesktop;
if (!bridge?.isDesktop) {
throw new Error('记忆系统仅支持桌面版');
}
return bridge;
}
/** 读取 MEMORY.md 原始内容(通过专用 IPC 通道) */
async function readMemoryFile(): Promise<string> {
const bridge = getBridge();
const result = await bridge.memoryAccess!.read();
if (!result.success) {
// 文件不存在视为空
if (result.error?.includes('ENOENT') || result.error?.includes('not found') || result.error?.includes('不存在')) {
return '';
}
throw new Error(`读取 MEMORY.md 失败: ${result.error}`);
}
return result.content || '';
}
/** 写入 MEMORY.md(通过专用 IPC 通道) */
async function writeMemoryFile(content: string): Promise<void> {
const bridge = getBridge();
const result = await bridge.memoryAccess!.write(content);
if (!result.success) {
throw new Error(`写入 MEMORY.md 失败: ${result.error}`);
}
}
// ═══════════════════════════════════════════════════════════════
// 解析与序列化
// ═══════════════════════════════════════════════════════════════
/**
* MEMORY.md
*/
export function parseMemoryMd(content: string): MemoryEntry[] {
if (!content || !content.trim()) return [];
const entries: MemoryEntry[] = [];
const lines = content.split('\n');
let currentEntry: MemoryEntry | null = null;
let contentLines: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const headerMatch = line.match(ENTRY_HEADER_RE);
if (headerMatch) {
// 保存上一个条目
if (currentEntry) {
currentEntry.content = contentLines.join('\n').trim();
if (currentEntry.content) entries.push(currentEntry);
}
// 解析新条目元数据
const type = headerMatch[1].toLowerCase() as MemoryType;
const id = headerMatch[2];
const importance = parseInt(headerMatch[3], 10);
const tagsStr = headerMatch[4];
const tags = tagsStr.split(',').map(t => t.trim()).filter(t => t.length > 0);
currentEntry = { id, type, content: '', importance: Math.min(10, Math.max(1, importance)), tags };
contentLines = [];
} else if (currentEntry) {
contentLines.push(line);
}
}
// 最后一个条目
if (currentEntry) {
currentEntry.content = contentLines.join('\n').trim();
if (currentEntry.content) entries.push(currentEntry);
}
return entries;
}
/**
* MEMORY.md
*/
export function validateMemoryMd(content: string): { valid: boolean; error?: string; line?: number } {
if (!content || !content.trim()) {
return { valid: false, error: '文件内容为空' };
}
const lines = content.split('\n');
const firstLine = lines[0]?.trim();
if (firstLine !== '# METONA MEMORY') {
return { valid: false, error: `文件必须以 "# METONA MEMORY" 开头,当前: "${firstLine?.slice(0, 50) || '(空)'}"`, line: 1 };
}
let entryCount = 0;
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
// 跳过注释行(以 > 开头)
if (line.trim().startsWith('>')) continue;
// 跳过空行
if (!line.trim()) continue;
const headerMatch = line.match(ENTRY_HEADER_RE);
if (!headerMatch) {
// 非元数据行的内容行(属于上一个条目)
continue;
}
entryCount++;
// 验证类型
const type = headerMatch[1].toLowerCase();
if (!VALID_TYPES.includes(type as MemoryType)) {
return { valid: false, error: `${i + 1} 行: 无效的记忆类型 "${type}",必须为 fact / preference / rule`, line: i + 1 };
}
// 验证 ID
const id = headerMatch[2];
if (!/^mem_\d{8}_\d{3}$/.test(id)) {
return { valid: false, error: `${i + 1} 行: 无效的 ID 格式 "${id}",必须为 mem_YYYYMMDD_NNN`, line: i + 1 };
}
// 验证 importance
const importance = parseInt(headerMatch[3], 10);
if (isNaN(importance) || importance < 1 || importance > 10) {
return { valid: false, error: `${i + 1} 行: importance 必须为 1-10 的整数,当前: "${headerMatch[3]}"`, line: i + 1 };
}
// 验证 tags
const tagsStr = headerMatch[4]?.trim();
if (!tagsStr) {
return { valid: false, error: `${i + 1} 行: tags 不能为空`, line: i + 1 };
}
const tags = tagsStr.split(',').map(t => t.trim()).filter(t => t.length > 0);
if (tags.length === 0) {
return { valid: false, error: `${i + 1} 行: tags 至少需要一个标签`, line: i + 1 };
}
}
if (entryCount === 0 && content.includes('## ')) {
// 有 ## 但没匹配到,格式可能有问题
}
return { valid: true };
}
/**
* MEMORY.md
*/
export function serializeMemoryMd(entries: MemoryEntry[]): string {
let content = FILE_HEADER;
for (const entry of entries) {
if (!entry.content.trim()) continue;
const tagsStr = entry.tags.join(', ');
content += `## ${entry.type} | id: ${entry.id} | importance: ${entry.importance} | tags: ${tagsStr}\n`;
content += entry.content.trim() + '\n\n';
}
return content;
}
// ═══════════════════════════════════════════════════════════════
// 搜索
// ═══════════════════════════════════════════════════════════════
/**
*
*/
export function searchMemory(entries: MemoryEntry[], query: string, limit = 8): MemorySearchResult[] {
if (!query || entries.length === 0) return [];
const queryLower = query.toLowerCase();
const queryWords = queryLower.split(/[\s,,。!?、;:""''()\[\]{}<>`~@#$%^&*+=|\\/.]+/).filter(w => w.length > 1);
// rule 和 preference 类型的记忆全局生效,始终注入
const alwaysInclude: MemorySearchResult[] = [];
const scored: MemorySearchResult[] = [];
for (const entry of entries) {
if (entry.type === 'rule' || entry.type === 'preference') {
alwaysInclude.push({ ...entry, score: 100 + entry.importance });
continue;
}
let score = 0;
const contentLower = entry.content.toLowerCase();
const tagsLower = entry.tags.map(t => t.toLowerCase());
if (contentLower.includes(queryLower)) score += 50;
for (const tag of tagsLower) {
if (queryLower.includes(tag) || tag.includes(queryLower)) score += 30;
for (const word of queryWords) {
if (tag.includes(word)) score += 15;
}
}
for (const word of queryWords) {
if (contentLower.includes(word)) score += 10;
}
score *= (0.5 + entry.importance / 20);
if (score > 0) scored.push({ ...entry, score });
}
// 去重 + 排序
const seen = new Set<string>();
const merged: MemorySearchResult[] = [];
for (const item of [...alwaysInclude, ...scored.sort((a, b) => b.score - a.score)]) {
if (!seen.has(item.id)) {
seen.add(item.id);
merged.push(item);
}
}
return merged.slice(0, limit);
}
// ═══════════════════════════════════════════════════════════════
// 上下文格式化
// ═══════════════════════════════════════════════════════════════
/**
*
*/
export function formatMemoryContext(memories: MemorySearchResult[]): string {
if (memories.length === 0) return '';
const grouped: Record<string, MemorySearchResult[]> = {};
for (const m of memories) {
if (!grouped[m.type]) grouped[m.type] = [];
grouped[m.type].push(m);
}
let context = '';
// 规则类:强制约束,放在最前面
if (grouped['rule']) {
context += '【必须严格遵守的规则】\n以下规则是用户明确要求的,必须无条件执行:\n';
for (const item of grouped['rule']) {
context += ` - ${item.content}\n`;
}
context += '\n';
delete grouped['rule'];
}
// 偏好类:用户偏好
if (grouped['preference']) {
context += '【用户偏好】\n在回答时请遵循以下偏好:\n';
for (const item of grouped['preference']) {
context += ` - ${item.content}\n`;
}
context += '\n';
delete grouped['preference'];
}
// 事实类:参考信息
if (grouped['fact']) {
context += '【关于用户的参考信息】\n以下信息供参考,与当前对话无关时可忽略:\n';
for (const item of grouped['fact']) {
context += ` - ${item.content}\n`;
}
context += '\n';
}
context += '请自然地利用以上信息,不要生硬地列举。';
return context;
}
// ═══════════════════════════════════════════════════════════════
// CRUD 操作(全部通过专用 IPC 通道读写 MEMORY.md
// ═══════════════════════════════════════════════════════════════
/** 生成新 ID */
function generateMemoryId(): string {
const now = new Date();
const dateStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
const seq = String(Math.floor(Math.random() * 1000)).padStart(3, '0');
return `mem_${dateStr}_${seq}`;
}
/** 加载全部条目 */
export async function loadAllEntries(): Promise<MemoryEntry[]> {
const content = await readMemoryFile();
if (!content.trim()) return [];
return parseMemoryMd(content);
}
/** 搜索记忆(读取 MEMORY.md → 解析 → 搜索) */
export async function search(query: string, limit = 8): Promise<MemorySearchResult[]> {
try {
const entries = await loadAllEntries();
return searchMemory(entries, query, limit);
} catch (err) {
logWarn('记忆搜索失败', (err as Error).message);
return [];
}
}
/** 添加记忆条目 */
export async function addEntry(
type: MemoryType,
content: string,
importance = 5,
tags: string[] = []
): Promise<MemoryEntry> {
if (!content || content.length < 2) {
throw new Error('记忆内容不能为空且至少 2 个字符');
}
if (!VALID_TYPES.includes(type)) {
throw new Error(`无效的记忆类型: ${type},必须为 fact / preference / rule`);
}
// 安全扫描
const securityCheck = scanMemorySecurity(content);
if (!securityCheck.safe) {
throw new Error(`记忆内容被安全规则拦截: ${securityCheck.reason}`);
}
// 自动提取标签
if (tags.length === 0) {
tags = extractTags(content);
}
// 读取现有条目
let entries = await loadAllEntries();
// 容量检查
if (entries.length >= MAX_ENTRIES) {
entries = autoCleanEntries(entries);
}
// 去重检查(前 50 字符相同视为重复)
const existing = entries.find(e => {
if (e.type !== type) return false;
const prefixLen = Math.min(50, content.length, e.content.length);
if (prefixLen > 20 && content.slice(0, prefixLen) === e.content.slice(0, prefixLen)) return true;
return simpleSimilarity(e.content, content) > 0.8;
});
if (existing) return existing;
const entry: MemoryEntry = {
id: generateMemoryId(),
type,
content: content.trim(),
importance: Math.min(10, Math.max(1, importance)),
tags: tags.slice(0, 5),
};
entries.push(entry);
const fileContent = serializeMemoryMd(entries);
const validation = validateMemoryMd(fileContent);
if (!validation.valid) {
throw new Error(`序列化后校验失败: ${validation.error}`);
}
await writeMemoryFile(fileContent);
logMemory(`新增: ${type}`, content.slice(0, 60));
return entry;
}
/** 替换记忆(子串匹配) */
export async function replaceEntry(oldText: string, newContent: string): Promise<{ success: boolean; message: string }> {
if (!oldText || oldText.length < 2) {
return { success: false, message: 'old_text 不能为空且至少 2 个字符' };
}
if (!newContent || newContent.length < 2) {
return { success: false, message: 'new_content 不能为空且至少 2 个字符' };
}
const securityCheck = scanMemorySecurity(newContent);
if (!securityCheck.safe) {
return { success: false, message: `新内容被安全规则拦截: ${securityCheck.reason}` };
}
const entries = await loadAllEntries();
const matches = entries.filter(e => e.content.includes(oldText));
if (matches.length === 0) {
return { success: false, message: `未找到包含 "${oldText.slice(0, 50)}" 的记忆` };
}
if (matches.length > 1) {
return { success: false, message: `匹配到 ${matches.length} 条记忆,请使用更精确的 old_text` };
}
const target = matches[0];
target.content = newContent.trim();
// 自动刷新标签
if (target.tags.length === 0) {
target.tags = extractTags(newContent);
}
const fileContent = serializeMemoryMd(entries);
const validation = validateMemoryMd(fileContent);
if (!validation.valid) {
return { success: false, message: `序列化后校验失败: ${validation.error}` };
}
await writeMemoryFile(fileContent);
logMemory('替换记忆', `${target.id}: ${oldText.slice(0, 30)}${newContent.slice(0, 30)}`);
return { success: true, message: `已替换记忆: ${target.id}` };
}
/** 删除记忆(子串匹配) */
export async function removeEntry(oldText: string): Promise<{ success: boolean; message: string }> {
if (!oldText || oldText.length < 2) {
return { success: false, message: 'old_text 不能为空且至少 2 个字符' };
}
const entries = await loadAllEntries();
const matches = entries.filter(e => e.content.includes(oldText));
if (matches.length === 0) {
return { success: false, message: `未找到包含 "${oldText.slice(0, 50)}" 的记忆` };
}
if (matches.length > 1) {
return { success: false, message: `匹配到 ${matches.length} 条记忆,请使用更精确的 old_text` };
}
const newEntries = entries.filter(e => e.id !== matches[0].id);
const fileContent = newEntries.length > 0 ? serializeMemoryMd(newEntries) : '';
if (fileContent) {
await writeMemoryFile(fileContent);
} else {
// 清空文件(写入空内容让主进程删除或留空)
await writeMemoryFile('');
}
logMemory('删除记忆', `${matches[0].id}: ${oldText.slice(0, 50)}`);
return { success: true, message: `已删除记忆: ${matches[0].id}` };
}
/** 清空所有记忆 */
export async function clearAll(): Promise<void> {
await writeMemoryFile('');
logMemory('清空', '所有记忆已删除');
}
// ═══════════════════════════════════════════════════════════════
// 工具函数
// ═══════════════════════════════════════════════════════════════
function extractTags(text: string): string[] {
const words = text
.replace(/[^\w\u4e00-\u9fff\s]/g, ' ')
.split(/\s+/)
.filter(w => w.length > 1);
return [...new Set(words)].slice(0, 5);
}
function simpleSimilarity(a: string, b: string): number {
const aWords = new Set(a.toLowerCase().split(/\s+/));
const bWords = new Set(b.toLowerCase().split(/\s+/));
let intersection = 0;
for (const w of aWords) {
if (bWords.has(w)) intersection++;
}
const union = aWords.size + bWords.size - intersection;
return union === 0 ? 0 : intersection / union;
}
/**
*
* rule
* @returns
*/
function autoCleanEntries(entries: MemoryEntry[]): MemoryEntry[] {
const now = Date.now();
const SEVEN_DAYS = 7 * 24 * 3600 * 1000;
// 计算评分(没有 useCount/lastUsedAt 等字段,只按 importance
const scored = entries
.map(entry => ({
entry,
score: entry.importance * 2,
}))
.filter(s => s.entry.type !== 'rule')
.sort((a, b) => a.score - b.score);
const evictCount = Math.max(1, Math.floor(entries.length * 0.2));
const evictIds = new Set(scored.slice(0, evictCount).map(s => s.entry.id));
logMemory('容量清理', `清理 ${evictIds.size} 条低价值记忆(上限 ${MAX_ENTRIES}`);
return entries.filter(e => !evictIds.has(e.id));
}
/**
*
*/
export function isMemoryAvailable(): boolean {
const bridge = window.metonaDesktop;
return !!(bridge?.isDesktop && bridge?.memoryAccess);
}
/**
*
*
*
* 1. >= 5 >= 3
* 2. LLM prompt 2 importance >= 7
* 3.
* 4.
*
* @returns
*/
export async function extractAndSaveMemories(
messages: Array<{ role: string; content: string }>,
api: any,
model: string,
abortSignal?: AbortSignal
): Promise<number> {
// ── 前置条件:对话太短不提取 ──
const userMsgs = messages.filter(m => m.role === 'user');
const assistantMsgs = messages.filter(m => m.role === 'assistant');
if (userMsgs.length < 3 || assistantMsgs.length < 2) {
logDebug('自动记忆跳过: 对话轮次不足', `用户${userMsgs.length}条, AI${assistantMsgs.length}`);
return 0;
}
// ── 前置条件:bridge 可用 ──
if (!isMemoryAvailable()) {
logDebug('自动记忆跳过: 非桌面环境');
return 0;
}
// ── 取最近 15 轮构建提取文本 ──
const recentMsgs = messages.slice(-30).filter(m => m.role === 'user' || m.role === 'assistant');
const conversationText = recentMsgs
.map(m => `[${m.role === 'user' ? '用户' : 'AI'}]: ${(m.content || '').slice(0, 800)}`)
.join('\n\n');
const extractPrompt = `分析以下对话,仅提取用户明确陈述的、有长期跨会话价值的个人信息。
${conversationText.slice(0, 5000)}
JSON 2
\`\`\`json
{
"entries": [
{"type": "fact", "content": "具体事实(不超过40字)", "importance": 8, "tags": ["关键词1", "关键词2"]}
]
}
\`\`\`
1. ****
2. ****
3. 40
4. importance 7-10 7+
5. tags 2-3
6. rule
- AI
- "用户喜欢编程"
- "帮我写个函数"
-
-
{"entries": []}`;
try {
// ── 调用 LLM 提取 ──
const response = await api.chat({
model,
messages: [{ role: 'user', content: extractPrompt }],
think: false,
options: { num_ctx: 8192, temperature: 0.1 }
});
// 检查中止信号
if (abortSignal?.aborted) {
logDebug('自动记忆提取已中止');
return 0;
}
const content = (response as { message?: { content?: string } })?.message?.content || '';
const jsonMatch = content.match(/```json\s*([\s\S]*?)\s*```/) || content.match(/\{[\s\S]*"entries"[\s\S]*\}/);
if (!jsonMatch) return 0;
const parsed: { entries?: Array<{ type: string; content: string; importance: number; tags: string[] }> } =
JSON.parse(jsonMatch[1] || jsonMatch[0]);
if (!parsed.entries?.length) return 0;
// ── 加载已有记忆用于去重 ──
const existingEntries = await loadAllEntries();
let count = 0;
const MAX_PER_EXTRACT = 2;
for (const entry of parsed.entries) {
if (count >= MAX_PER_EXTRACT) break;
// ── 过滤 1: 类型校验(仅 fact / preference,禁止 rule)──
if (!['fact', 'preference'].includes(entry.type)) {
logDebug('自动记忆过滤: 无效类型', entry.type);
continue;
}
// ── 过滤 2: 内容质量 ──
if (!entry.content || entry.content.length < 8 || entry.content.length > 100) {
logDebug('自动记忆过滤: 内容长度不符', `${entry.content?.length || 0}字符`);
continue;
}
// ── 过滤 3: 泛化检测(过于宽泛的表述)──
const genericPatterns = [
/^用户是/, /^用户喜欢/, /^用户正在/, /^用户需要/,
/^用户想要/, /^用户希望/, /^用户使用/, /^用户做/,
];
const isGeneric = genericPatterns.some(p => p.test(entry.content)) && entry.content.length < 15;
if (isGeneric) {
logDebug('自动记忆过滤: 过于泛化', entry.content);
continue;
}
// ── 过滤 4: importance 门槛(>= 7)──
const importance = Math.min(10, Math.max(1, entry.importance || 5));
if (importance < 7) {
logDebug('自动记忆过滤: importance 不足', `${importance}`);
continue;
}
// ── 过滤 5: 去重(与已有记忆比对)──
const isDuplicate = existingEntries.some(e => {
if (e.type !== entry.type) return false;
const prefixLen = Math.min(40, entry.content.length, e.content.length);
if (prefixLen > 15 && entry.content.slice(0, prefixLen) === e.content.slice(0, prefixLen)) return true;
return simpleSimilarity(e.content, entry.content) > 0.75;
});
if (isDuplicate) {
logDebug('自动记忆过滤: 重复', entry.content.slice(0, 40));
continue;
}
// ── 保存 ──
try {
await addEntry(
entry.type as MemoryType,
entry.content.trim(),
importance,
(entry.tags || []).slice(0, 3)
);
existingEntries.push({
id: '', type: entry.type as MemoryType, content: entry.content.trim(),
importance, tags: (entry.tags || []).slice(0, 3),
});
count++;
} catch (err) {
logDebug('自动记忆保存失败', (err as Error).message);
}
}
if (count > 0) {
logInfo('🧠 自动记忆提取完成', `从对话中提取了 ${count} 条有价值记忆`);
}
return count;
} catch (err) {
logDebug('自动记忆提取异常', (err as Error).message);
return 0;
}
}
/**
* MEMORY.md
* -
* - .bak
* -
*
*
*/
export async function initMemoryFile(): Promise<{ action: string; existed: boolean; valid: boolean; backedUp?: string }> {
const bridge = window.metonaDesktop;
if (!bridge?.isDesktop || !bridge?.memoryAccess) {
logWarn('记忆初始化跳过', '非桌面环境或 bridge 不可用');
return { action: 'skipped', existed: false, valid: false };
}
try {
const result = await bridge.memoryAccess.init();
if (!result.success) {
logWarn('MEMORY.md 初始化失败', result.error || '未知错误');
return { action: 'failed', existed: false, valid: false };
}
// 日志已由主进程打印,渲染进程补充简要汇总
switch (result.action) {
case 'created':
logInfo('🧠 MEMORY.md 已创建(文件不存在,自动初始化)');
break;
case 'recreated':
logWarn('🧠 MEMORY.md 格式错误已修复', `原文件备份至: ${result.backedUp || 'MEMORY.md.bak'}`);
break;
case 'valid':
logInfo('🧠 MEMORY.md 格式校验通过', result.existed ? '文件已存在' : '');
break;
}
return {
action: result.action,
existed: result.existed,
valid: result.valid,
backedUp: result.backedUp,
};
} catch (err) {
logWarn('MEMORY.md 初始化异常', (err as Error).message);
return { action: 'failed', existed: false, valid: false };
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ const SUB_AGENT_TOOL_WHITELIST = new Set([
'read_file', 'list_directory', 'search_files', 'read_file', 'list_directory', 'search_files',
'web_search', 'web_fetch', 'web_search', 'web_fetch',
'browser_extract', 'browser_screenshot', 'browser_extract', 'browser_screenshot',
'memory_search', 'memory',
'session_list', 'session_read', 'session_list', 'session_read',
]); ]);
+52 -86
View File
@@ -351,72 +351,33 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
} }
}, },
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
// v4.0 新增工具:记忆和会话管理 // 记忆工具(统一入口,读写工作空间 MEMORY.md)
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
{ {
type: 'function', type: 'function',
function: { function: {
name: 'memory_search', name: 'memory',
description: 'Search agent memories by semantic similarity or keywords. Returns relevant memories about the user, preferences, rules, and past conversations.', description: 'Manage agent memories stored in MEMORY.md in the workspace. Supports: search (keyword search across all entries), add (append new entry with auto-generated ID), replace (substring match replace), remove (substring match delete), read_all (read all entries). Memories are automatically injected into system prompt on new conversations.',
parameters: { parameters: {
type: 'object', type: 'object',
required: ['query'], required: ['action'],
properties: { properties: {
query: { type: 'string', description: 'Search query for memories.' }, action: { type: 'string', enum: ['search', 'add', 'replace', 'remove', 'read_all'], description: 'Action: search=keyword match search, add=append entry, replace=find by old_text and replace, remove=find by old_text and delete, read_all=return all entries.' },
limit: { type: 'integer', description: 'Max results to return. Default: 8.' }, query: { type: 'string', description: 'Search query for search action.' },
type: { type: 'string', enum: ['fact', 'preference', 'rule'], description: 'Filter by memory type.' } limit: { type: 'integer', description: 'Max results for search. Default: 8.' },
} type: { type: 'string', enum: ['fact', 'preference', 'rule'], description: 'Memory type for add. fact=about user, preference=user preference, rule=must follow.' },
} content: { type: 'string', description: 'Memory content for add. Concise and specific.' },
} importance: { type: 'integer', description: 'Importance 1-10 for add. Default: 5.' },
}, tags: { type: 'array', items: { type: 'string' }, description: 'Tags for add. 2-5 keywords for search matching.' },
{ old_text: { type: 'string', description: 'Unique substring to find the entry for replace/remove.' },
type: 'function', new_content: { type: 'string', description: 'New content for replace action.' }
function: {
name: 'memory_add',
description: 'Add a new memory entry for future reference. Use when you learn something important about the user, their preferences, or rules they want you to follow.',
parameters: {
type: 'object',
required: ['type', 'content'],
properties: {
type: { type: 'string', enum: ['fact', 'preference', 'rule'], description: 'Memory type: fact (about user), preference (user preference), rule (must follow).' },
content: { type: 'string', description: 'The memory content. Be concise and specific.' },
importance: { type: 'integer', description: 'Importance 1-10. Higher = more important. Default: 5.' },
tags: { type: 'array', items: { type: 'string' }, description: 'Tags for search matching. 2-5 keywords.' }
}
}
}
},
{
type: 'function',
function: {
name: 'memory_replace',
description: 'Replace an existing memory entry with updated content. Uses substring matching to find the entry to replace.',
parameters: {
type: 'object',
required: ['target', 'old_text', 'new_content'],
properties: {
target: { type: 'string', enum: ['memory', 'user'], description: 'Target store: memory (agent notes) or user (user profile).' },
old_text: { type: 'string', description: 'Unique substring that identifies the entry to replace.' },
new_content: { type: 'string', description: 'The new content to replace the old entry with.' }
}
}
}
},
{
type: 'function',
function: {
name: 'memory_remove',
description: 'Remove a memory entry. Uses substring matching to find the entry to remove.',
parameters: {
type: 'object',
required: ['target', 'old_text'],
properties: {
target: { type: 'string', enum: ['memory', 'user'], description: 'Target store: memory (agent notes) or user (user profile).' },
old_text: { type: 'string', description: 'Unique substring that identifies the entry to remove.' }
} }
} }
} }
}, },
// ══════════════════════════════════════════════
// 会话管理工具
// ══════════════════════════════════════════════
{ {
type: 'function', type: 'function',
function: { function: {
@@ -733,7 +694,7 @@ let enabledTools: Set<string> = new Set([
'move_file', 'copy_file', 'web_fetch', 'web_search', 'edit_file', 'move_file', 'copy_file', 'web_fetch', 'web_search', 'edit_file',
'get_file_info', 'tree', 'download_file', 'diff_files', 'replace_in_files', 'get_file_info', 'tree', 'download_file', 'diff_files', 'replace_in_files',
'read_multiple_files', 'git', 'compress', 'read_multiple_files', 'git', 'compress',
'memory_search', 'memory_add', 'memory_replace', 'memory_remove', 'memory',
'session_list', 'session_read', 'spawn_task', 'session_list', 'session_read', 'spawn_task',
'browser_open', 'browser_screenshot', 'browser_evaluate', 'browser_extract', 'browser_open', 'browser_screenshot', 'browser_evaluate', 'browser_extract',
'browser_click', 'browser_type', 'browser_scroll', 'browser_wait', 'browser_close', 'browser_click', 'browser_type', 'browser_scroll', 'browser_wait', 'browser_close',
@@ -809,46 +770,51 @@ export async function executeTool(toolName: string, args: Record<string, unknown
} }
try { try {
// 内存工具(不走 IPC,直接处理) // 内存工具(不走 IPC,直接处理 MEMORY.md
if (toolName === 'memory_search') { if (toolName === 'memory') {
const { searchMemories } = await import('./memory-manager.js'); const { search, addEntry, replaceEntry, removeEntry, loadAllEntries } = await import('./memory-service.js');
const action = args.action as string;
switch (action) {
case 'search': {
const query = args.query as string; const query = args.query as string;
const limit = (args.limit as number) || 8; const limit = (args.limit as number) || 8;
const type = args.type as string | undefined; if (!query) return { success: false, error: '缺少 query 参数' };
let results = searchMemories(query, limit); const results = await search(query, limit);
if (type) results = results.filter(r => r.type === type); logToolResult('memory', true, `${results.length} 条结果`);
logToolResult('memory_search', true, `${results.length} 条结果`); return { success: true, action, results, total: results.length };
return { success: true, results, total: results.length };
} }
if (toolName === 'memory_add') { case 'add': {
const { addMemory } = await import('./memory-manager.js');
const type = args.type as 'fact' | 'preference' | 'rule'; const type = args.type as 'fact' | 'preference' | 'rule';
const content = args.content as string; const content = args.content as string;
const importance = (args.importance as number) || 5; const importance = (args.importance as number) || 5;
const tags = (args.tags as string[]) || []; const tags = (args.tags as string[]) || [];
if (!content || content.length < 2) { if (!type) return { success: false, error: '缺少 type 参数 (fact/preference/rule)' };
return { success: false, error: 'content 不能为空且至少 2 个字符' }; if (!content || content.length < 2) return { success: false, error: 'content 不能为空且至少 2 个字符' };
const entry = await addEntry(type, content, importance, tags);
logToolResult('memory', true, `${type}: ${content.slice(0, 50)}`);
return { success: true, action, id: entry.id, type: entry.type, content: entry.content };
} }
const entry = await addMemory({ type, content, importance, tags }); case 'replace': {
logToolResult('memory_add', true, `${type}: ${content.slice(0, 50)}`);
return { success: true, id: entry.id, type: entry.type, content: entry.content };
}
if (toolName === 'memory_replace') {
const { replaceMemoryByContent } = await import('./memory-manager.js');
const target = String(args.target || 'memory') as 'memory' | 'user';
const oldText = String(args.old_text || ''); const oldText = String(args.old_text || '');
const newContent = String(args.new_content || ''); const newContent = String(args.new_content || '');
const result = await replaceMemoryByContent(target, oldText, newContent); const result = await replaceEntry(oldText, newContent);
logToolResult('memory_replace', result.success, result.message); logToolResult('memory', result.success, result.message);
return result; return { success: result.success, action, ...(result.success ? {} : { error: result.message }), message: result.message };
} }
if (toolName === 'memory_remove') { case 'remove': {
const { removeMemoryByContent } = await import('./memory-manager.js');
const target = String(args.target || 'memory') as 'memory' | 'user';
const oldText = String(args.old_text || ''); const oldText = String(args.old_text || '');
const result = await removeMemoryByContent(target, oldText); const result = await removeEntry(oldText);
logToolResult('memory_remove', result.success, result.message); logToolResult('memory', result.success, result.message);
return result; return { success: result.success, action, ...(result.success ? {} : { error: result.message }), message: result.message };
}
case 'read_all': {
const entries = await loadAllEntries();
logToolResult('memory', true, `${entries.length} 条记忆`);
return { success: true, action, entries, total: entries.length };
}
default:
return { success: false, error: `未知操作: ${action}。支持: search / add / replace / remove / read_all` };
}
} }
if (toolName === 'session_list') { if (toolName === 'session_list') {
const bridge = window.metonaDesktop; const bridge = window.metonaDesktop;
@@ -978,7 +944,7 @@ export function getToolIcon(name: string): string {
edit_file: '✂️', get_file_info: '️', tree: '🌳', download_file: '⬇️', edit_file: '✂️', get_file_info: '️', tree: '🌳', download_file: '⬇️',
diff_files: '🔀', replace_in_files: '🔄', read_multiple_files: '📚', diff_files: '🔀', replace_in_files: '🔄', read_multiple_files: '📚',
git: '🔖', compress: '🗜️', web_search: '🔍', git: '🔖', compress: '🗜️', web_search: '🔍',
memory_search: '🧠', memory_add: '💾', memory_replace: '🔄', memory_remove: '🗑️', memory: '🧠',
session_list: '📋', session_read: '📖', spawn_task: '🤖', session_list: '📋', session_read: '📖', spawn_task: '🤖',
plan_track: '📋', plan_track: '📋',
browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰', browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰',
@@ -1054,7 +1020,7 @@ export function formatToolName(name: string): string {
get_file_info: '文件信息', tree: '目录树', download_file: '下载文件', get_file_info: '文件信息', tree: '目录树', download_file: '下载文件',
diff_files: '文件对比', replace_in_files: '批量替换', read_multiple_files: '批量读取', diff_files: '文件对比', replace_in_files: '批量替换', read_multiple_files: '批量读取',
git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索', git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索',
memory_search: '搜索记忆', memory_add: '添加记忆', memory_replace: '替换记忆', memory_remove: '删除记忆', memory: '记忆管理',
session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派', session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派',
plan_track: '计划追踪', plan_track: '计划追踪',
browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容', browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容',
-172
View File
@@ -1,172 +0,0 @@
/**
* VectorMemory -
* rag.ts
*/
import { state, KEYS } from '../state/state.js';
import { VectorStore } from './vector-store.js';
import { logMemory, logDebug, logError } from './log-service.js';
import type { MemoryEntry, VectorItem, SearchResult } from '../types.js';
import type { OllamaAPI } from '../api/ollama.js';
const MEMORY_COLLECTION_NAME = '记忆向量索引';
let vectorStore: VectorStore | null = null;
let memoryCollectionId: string | null = null;
// ── 初始化 ──
export async function initMemoryVectorStore(): Promise<VectorStore> {
if (vectorStore) return vectorStore;
vectorStore = new VectorStore();
await vectorStore.init();
logDebug('记忆向量存储已初始化');
return vectorStore;
}
export function getMemoryVectorStore(): VectorStore | null {
return vectorStore;
}
// ── 集合管理 ──
export async function getOrCreateMemoryCollection(embedModel: string): Promise<{ id: string; isNew: boolean }> {
const vs = await initMemoryVectorStore();
// 如果已有缓存的集合ID,验证它是否存在
if (memoryCollectionId) {
const existing = await vs.getCollection(memoryCollectionId);
if (existing) {
if (existing.embeddingModel !== embedModel) {
existing.embeddingModel = embedModel;
await vs.updateCollection(existing);
}
return { id: memoryCollectionId, isNew: false };
}
memoryCollectionId = null;
}
// 搜索现有集合
const collections = await vs.getCollections();
const found = collections.find(c => c.name === MEMORY_COLLECTION_NAME);
if (found) {
memoryCollectionId = found.id;
if (found.embeddingModel !== embedModel) {
found.embeddingModel = embedModel;
await vs.updateCollection(found);
}
return { id: found.id, isNew: false };
}
// 创建新集合
const col = await vs.createCollection(MEMORY_COLLECTION_NAME, embedModel);
memoryCollectionId = col.id;
logMemory('创建记忆向量集合', col.id);
return { id: col.id, isNew: true };
}
export function getMemoryCollectionId(): string | null {
return memoryCollectionId;
}
export function setMemoryCollectionId(id: string | null): void {
memoryCollectionId = id;
}
// ── 文本嵌入 ──
export async function embedText(text: string, model: string): Promise<number[]> {
const api = state.get<OllamaAPI>(KEYS.API);
if (!api) throw new Error('Ollama API 未连接');
const result = await api.embed(model, text);
if (result.embedding) return result.embedding;
if (result.embeddings && result.embeddings[0]) return result.embeddings[0];
throw new Error('嵌入向量返回格式异常');
}
// ── 为记忆条目生成向量 ──
export async function embedMemoryEntry(entry: MemoryEntry, model: string): Promise<number[]> {
const text = `[${entry.type}] ${entry.content} ${entry.tags.join(' ')}`;
return embedText(text, model);
}
// ── 添加记忆向量 ──
export async function addMemoryVector(entry: MemoryEntry, embedding: number[], colId: string): Promise<void> {
const vs = await initMemoryVectorStore();
const item: VectorItem = {
id: `vec_${entry.id}`,
collectionId: colId,
docId: entry.id,
filename: entry.type,
chunkIndex: 0,
text: entry.content,
charCount: entry.content.length,
embedding
};
await vs.addVectors([item]);
}
// ── 更新记忆向量 ──
export async function updateMemoryVector(entry: MemoryEntry, embedding: number[], colId: string): Promise<void> {
// 先删除旧的,再添加新的
await deleteMemoryVector(entry.id, colId);
await addMemoryVector(entry, embedding, colId);
}
// ── 删除记忆向量 ──
export async function deleteMemoryVector(entryId: string, colId: string): Promise<void> {
const vs = await initMemoryVectorStore();
await vs.deleteVectorsByDocument(colId, entryId);
}
// ── 向量搜索记忆 ──
export async function searchMemoriesByVector(query: string, colId: string, topK = 8): Promise<SearchResult[]> {
const vs = await initMemoryVectorStore();
const col = await vs.getCollection(colId);
if (!col || !col.embeddingModel) return [];
const queryEmbedding = await embedText(query, col.embeddingModel);
const results = await vs.search(colId, queryEmbedding, topK);
return results;
}
// ── 重新索引所有记忆 ──
export async function reindexAllMemories(
entries: MemoryEntry[],
embedModel: string,
colId: string,
onProgress?: (done: number, total: number) => void
): Promise<void> {
const vs = await initMemoryVectorStore();
// 清空集合中的旧向量
const oldVectors = await vs.getVectorsByCollection(colId);
if (oldVectors.length > 0) {
await vs.deleteCollection(colId);
const col = await vs.createCollection(MEMORY_COLLECTION_NAME, embedModel);
memoryCollectionId = col.id;
colId = col.id;
}
logMemory(`开始重新索引: ${entries.length} 条记忆`);
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
try {
const embedding = await embedMemoryEntry(entry, embedModel);
await addMemoryVector(entry, embedding, colId);
} catch (err) {
logError(`索引记忆失败: ${entry.id}`, (err as Error).message);
}
if (onProgress) onProgress(i + 1, entries.length);
}
logMemory(`重新索引完成: ${entries.length}`);
}
-371
View File
@@ -1,371 +0,0 @@
/**
* VectorStore -
*/
import type { VectorCollection, VectorItem, SearchResult } from '../types.js';
import { logRAG, logDebug } from './log-service.js';
const MIN_IVF_SIZE = 200;
const DEFAULT_K = 20;
const DEFAULT_NPROBE = 5;
const KMEANS_ITERS = 10;
interface IVFIndex {
centroids: number[][];
invertedLists: Map<number, string[]>;
collectionId?: string;
version?: string;
}
export class VectorStore {
private dbName: string;
private db: IDBDatabase | null = null;
private _indexCache = new Map<string, IVFIndex>();
private _vectorCache = new Map<string, Map<string, VectorItem>>();
constructor(dbName = 'metona-ollama-vectors') {
this.dbName = dbName;
}
async init(): Promise<void> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(this.dbName, 2);
req.onerror = () => reject(req.error);
req.onsuccess = () => { this.db = req.result; logDebug('向量数据库已连接'); resolve(); };
req.onupgradeneeded = (e) => {
const db = (e.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains('vectors')) {
const store = db.createObjectStore('vectors', { keyPath: 'id' });
store.createIndex('collectionId', 'collectionId', { unique: false });
}
if (!db.objectStoreNames.contains('collections')) {
db.createObjectStore('collections', { keyPath: 'id' });
}
if (!db.objectStoreNames.contains('indexes')) {
db.createObjectStore('indexes', { keyPath: 'collectionId' });
}
};
});
}
private _tx(store: string, mode: IDBTransactionMode = 'readonly'): IDBObjectStore {
return this.db!.transaction(store, mode).objectStore(store);
}
async createCollection(name: string, embeddingModel = ''): Promise<VectorCollection> {
const col: VectorCollection = {
id: `mem_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
name, embeddingModel,
docCount: 0, chunkCount: 0,
createdAt: Date.now(), updatedAt: Date.now()
};
return new Promise((resolve, reject) => {
const req = this._tx('collections', 'readwrite').put(col);
req.onsuccess = () => { logRAG(`创建集合: ${name}`, col.id); resolve(col); };
req.onerror = () => reject(req.error);
});
}
async getCollections(): Promise<VectorCollection[]> {
return new Promise((resolve, reject) => {
const req = this._tx('collections').getAll();
req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error);
});
}
async getCollection(id: string): Promise<VectorCollection | null> {
return new Promise((resolve, reject) => {
const req = this._tx('collections').get(id);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => reject(req.error);
});
}
async updateCollection(col: VectorCollection): Promise<void> {
col.updatedAt = Date.now();
return new Promise((resolve, reject) => {
const req = this._tx('collections', 'readwrite').put(col);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
async deleteCollection(colId: string): Promise<void> {
const vectors = await this.getVectorsByCollection(colId);
logRAG(`删除集合`, `${colId} (${vectors.length} 向量)`);
await new Promise<void>((resolve, reject) => {
const tx = this.db!.transaction(['vectors', 'collections', 'indexes'], 'readwrite');
const vStore = tx.objectStore('vectors');
for (const v of vectors) vStore.delete(v.id);
tx.objectStore('collections').delete(colId);
tx.objectStore('indexes').delete(colId);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
this._indexCache.delete(colId);
this._vectorCache.delete(colId);
}
async addVectors(items: VectorItem[]): Promise<void> {
if (items.length === 0) return;
const colId = items[0].collectionId;
logDebug(`写入向量`, `${colId}: ${items.length}`);
await new Promise<void>((resolve, reject) => {
const tx = this.db!.transaction('vectors', 'readwrite');
const store = tx.objectStore('vectors');
for (const item of items) store.put(item);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
if (!this._vectorCache.has(colId)) {
this._vectorCache.set(colId, new Map());
}
const cache = this._vectorCache.get(colId)!;
for (const item of items) cache.set(item.id, item);
this._indexCache.delete(colId);
}
async getVectorsByCollection(colId: string): Promise<VectorItem[]> {
if (this._vectorCache.has(colId)) {
return Array.from(this._vectorCache.get(colId)!.values());
}
const vectors = await new Promise<VectorItem[]>((resolve, reject) => {
const idx = this._tx('vectors').index('collectionId');
const req = idx.getAll(colId);
req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error);
});
const cache = new Map<string, VectorItem>();
for (const v of vectors) cache.set(v.id, v);
this._vectorCache.set(colId, cache);
return vectors;
}
async deleteVectorsByDocument(colId: string, docId: string): Promise<void> {
const vectors = await this.getVectorsByCollection(colId);
const docVectors = vectors.filter(v => v.docId === docId);
logDebug(`删除文档向量`, `${docId}: ${docVectors.length}`);
await new Promise<void>((resolve, reject) => {
const tx = this.db!.transaction('vectors', 'readwrite');
const store = tx.objectStore('vectors');
for (const v of docVectors) store.delete(v.id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
if (this._vectorCache.has(colId)) {
const cache = this._vectorCache.get(colId)!;
for (const v of docVectors) cache.delete(v.id);
}
this._indexCache.delete(colId);
}
async search(colId: string, queryEmbedding: number[], topK = 5): Promise<SearchResult[]> {
const vectors = await this.getVectorsByCollection(colId);
if (vectors.length === 0) return [];
let results: SearchResult[];
if (vectors.length < MIN_IVF_SIZE) {
results = this._bruteForceSearch(vectors, queryEmbedding, topK);
} else {
const index = await this._getIndex(colId, vectors);
results = index ? this._ivfSearch(vectors, index, queryEmbedding, topK) : this._bruteForceSearch(vectors, queryEmbedding, topK);
}
logDebug(`向量搜索`, `${colId}: ${vectors.length} 向量中检索到 ${results.length} 个结果`);
return results;
}
private _bruteForceSearch(vectors: VectorItem[], queryEmbedding: number[], topK: number): SearchResult[] {
return vectors
.map(v => ({ ...v, score: VectorStore.cosineSimilarity(queryEmbedding, v.embedding) }))
.sort((a, b) => b.score - a.score)
.slice(0, topK);
}
private _ivfSearch(vectors: VectorItem[], index: IVFIndex, queryEmbedding: number[], topK: number): SearchResult[] {
const { centroids, invertedLists } = index;
const clusterScores = centroids.map((c, i) => ({
id: i,
score: VectorStore.cosineSimilarity(queryEmbedding, c)
}));
clusterScores.sort((a, b) => b.score - a.score);
const nprobe = Math.min(DEFAULT_NPROBE, centroids.length);
const targetClusters = clusterScores.slice(0, nprobe);
const candidateIds = new Set<string>();
for (const { id: clusterId } of targetClusters) {
const list = invertedLists.get(clusterId);
if (list) for (const id of list) candidateIds.add(id);
}
const vectorMap = new Map<string, VectorItem>();
for (const v of vectors) vectorMap.set(v.id, v);
const candidates: VectorItem[] = [];
for (const id of candidateIds) {
const v = vectorMap.get(id);
if (v) candidates.push(v);
}
return candidates
.map(v => ({ ...v, score: VectorStore.cosineSimilarity(queryEmbedding, v.embedding) }))
.sort((a, b) => b.score - a.score)
.slice(0, topK);
}
private async _getIndex(colId: string, vectors: VectorItem[]): Promise<IVFIndex | null> {
if (this._indexCache.has(colId)) {
const cached = this._indexCache.get(colId)!;
if (cached.version === this._indexVersion(vectors)) return cached;
}
const saved = await new Promise<IVFIndex & { invertedListsObj?: Record<string, string[]> } | null>((resolve, reject) => {
const req = this._tx('indexes').get(colId);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => reject(req.error);
});
if (saved && saved.version === this._indexVersion(vectors)) {
if (!(saved.invertedLists instanceof Map)) {
saved.invertedLists = new Map(Object.entries(saved.invertedListsObj || {}).map(([k, v]) => [parseInt(k), v as string[]]));
}
this._indexCache.set(colId, saved);
return saved;
}
const index = this._buildIVFIndex(vectors);
index.collectionId = colId;
index.version = this._indexVersion(vectors);
const toSave = {
collectionId: index.collectionId,
version: index.version,
centroids: index.centroids,
invertedListsObj: Object.fromEntries(index.invertedLists)
};
await new Promise<void>((resolve, reject) => {
const req = this._tx('indexes', 'readwrite').put(toSave);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
this._indexCache.set(colId, index);
return index;
}
private _indexVersion(vectors: VectorItem[]): string {
if (vectors.length === 0) return '0';
return `${vectors.length}_${vectors[0]?.id || ''}_${vectors[vectors.length - 1]?.id || ''}`;
}
private _buildIVFIndex(vectors: VectorItem[]): IVFIndex {
const N = vectors.length;
const dim = vectors[0].embedding.length;
const K = Math.max(2, Math.min(DEFAULT_K, Math.floor(N / 10)));
const centroids = this._kmeansPPInit(vectors, K, dim);
const assignments = new Array<number>(N);
for (let iter = 0; iter < KMEANS_ITERS; iter++) {
for (let i = 0; i < N; i++) {
let bestCluster = 0;
let bestScore = -Infinity;
for (let k = 0; k < K; k++) {
const score = VectorStore.cosineSimilarity(vectors[i].embedding, centroids[k]);
if (score > bestScore) { bestScore = score; bestCluster = k; }
}
assignments[i] = bestCluster;
}
const newCentroids = Array.from({ length: K }, () => new Array<number>(dim).fill(0));
const counts = new Array<number>(K).fill(0);
for (let i = 0; i < N; i++) {
const k = assignments[i];
counts[k]++;
const emb = vectors[i].embedding;
for (let d = 0; d < dim; d++) newCentroids[k][d] += emb[d];
}
for (let k = 0; k < K; k++) {
if (counts[k] > 0) {
for (let d = 0; d < dim; d++) newCentroids[k][d] /= counts[k];
this._normalize(newCentroids[k]);
} else {
newCentroids[k] = [...vectors[Math.floor(Math.random() * N)].embedding];
}
}
let converged = true;
for (let k = 0; k < K; k++) {
if (VectorStore.cosineSimilarity(centroids[k], newCentroids[k]) < 0.999) {
converged = false;
break;
}
}
for (let k = 0; k < K; k++) centroids[k] = newCentroids[k];
if (converged) break;
}
const invertedLists = new Map<number, string[]>();
for (let i = 0; i < N; i++) {
const k = assignments[i];
if (!invertedLists.has(k)) invertedLists.set(k, []);
invertedLists.get(k)!.push(vectors[i].id);
}
return { centroids, invertedLists };
}
private _kmeansPPInit(vectors: VectorItem[], K: number, dim: number): number[][] {
const centroids: number[][] = [];
const first = Math.floor(Math.random() * vectors.length);
centroids.push([...vectors[first].embedding]);
for (let k = 1; k < K; k++) {
const dists = vectors.map(v => {
let minDist = Infinity;
for (const c of centroids) {
const sim = VectorStore.cosineSimilarity(v.embedding, c);
const dist = 1 - sim;
if (dist < minDist) minDist = dist;
}
return minDist;
});
const total = dists.reduce((s, d) => s + d, 0);
if (total === 0) {
centroids.push([...vectors[Math.floor(Math.random() * vectors.length)].embedding]);
continue;
}
let r = Math.random() * total;
for (let i = 0; i < vectors.length; i++) {
r -= dists[i];
if (r <= 0) { centroids.push([...vectors[i].embedding]); break; }
}
}
return centroids;
}
private _normalize(vec: number[]): void {
let norm = 0;
for (let i = 0; i < vec.length; i++) norm += vec[i] * vec[i];
norm = Math.sqrt(norm);
if (norm > 0) for (let i = 0; i < vec.length; i++) vec[i] /= norm;
}
static cosineSimilarity(a: number[], b: number[]): number {
if (!a || !b || a.length !== b.length) return 0;
let dot = 0, normA = 0, normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
const denom = Math.sqrt(normA) * Math.sqrt(normB);
return denom === 0 ? 0 : dot / denom;
}
}
+5 -86
View File
@@ -133,67 +133,6 @@ export interface ChatSession {
updatedAt: number; updatedAt: number;
} }
// ── Agent 记忆系统类型 ──
export type MemoryType = 'fact' | 'preference' | 'rule';
export interface MemoryEntry {
id: string;
type: MemoryType;
content: string;
importance: number; // 1-10,越高越重要
tags: string[]; // 用于快速匹配
source?: string; // 来源描述(如会话标题)
sessionId?: string; // 关联的会话 ID
embedding?: number[]; // 向量嵌入
createdAt: number;
updatedAt: number;
lastUsedAt: number; // 最后一次被回忆的时间
useCount: number; // 被回忆的次数
}
export interface MemorySearchResult extends MemoryEntry {
score: number; // 相关性得分
}
export interface MemoryExtractionResult {
entries: Array<{
type: MemoryType;
content: string;
importance: number;
tags: string[];
}>;
}
// ── 向量存储类型(用于记忆向量索引)──
export interface VectorCollection {
id: string;
name: string;
embeddingModel: string;
docCount: number;
chunkCount: number;
createdAt: number;
updatedAt: number;
}
export interface VectorItem {
id: string;
collectionId: string;
docId: string;
filename: string;
chunkIndex: number;
text: string;
charCount: number;
embedding: number[];
}
export interface SearchResult extends VectorItem {
score: number;
}
// ── 桌面 Bridge 类型 ── // ── 桌面 Bridge 类型 ──
export interface WorkspaceFileEntry { export interface WorkspaceFileEntry {
@@ -287,6 +226,11 @@ export interface MetonaDesktopAPI {
onProgress: (callback: (data: { current: number }) => void) => void; onProgress: (callback: (data: { current: number }) => void) => void;
removeProgressListener: () => void; removeProgressListener: () => void;
}; };
memoryAccess?: {
read: () => Promise<{ success: boolean; content?: string; error?: string }>;
write: (content: string) => Promise<{ success: boolean; error?: string }>;
init: () => Promise<{ success: boolean; action: string; existed: boolean; valid: boolean; backedUp?: string; error?: string }>;
};
} }
export interface AppInfo { export interface AppInfo {
@@ -323,9 +267,6 @@ export type StateKey =
| 'thinkEnabled' | 'thinkEnabled'
| 'toolCallingEnabled' | 'toolCallingEnabled'
| 'runCommandMode' | 'runCommandMode'
| 'memoryEnabled'
| 'memoryEntries'
| 'embeddingModel'
| '_defaultModel' | '_defaultModel'
| '_lastSystemPrompt' | '_lastSystemPrompt'
| '_currentEvalCount' | '_currentEvalCount'
@@ -574,21 +515,6 @@ export interface MessageRow {
created_at: number; 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 DBAPI { export interface DBAPI {
saveSession: (session: SessionRow) => Promise<{ success: boolean; id?: string; error?: string }>; saveSession: (session: SessionRow) => Promise<{ success: boolean; id?: string; error?: string }>;
getSession: (id: string) => Promise<SessionRow | null>; getSession: (id: string) => Promise<SessionRow | null>;
@@ -597,13 +523,6 @@ export interface DBAPI {
clearAllSessions: () => Promise<{ success: boolean; error?: string }>; clearAllSessions: () => Promise<{ success: boolean; error?: string }>;
saveMessage: (msg: MessageRow) => Promise<{ success: boolean; id?: string; error?: string }>; saveMessage: (msg: MessageRow) => Promise<{ success: boolean; id?: string; error?: string }>;
getMessages: (sessionId: string) => Promise<MessageRow[]>; getMessages: (sessionId: string) => Promise<MessageRow[]>;
saveMemory: (entry: MemoryRow) => Promise<{ success: boolean; id?: string; error?: string }>;
getMemory: (id: string) => Promise<MemoryRow | null>;
getAllMemories: () => Promise<MemoryRow[]>;
getMemoriesByType: (type: string) => Promise<MemoryRow[]>;
deleteMemory: (id: string) => Promise<{ success: boolean; error?: string }>;
clearAllMemories: () => Promise<{ success: boolean; error?: string }>;
searchMemories: (query: string, limit?: number) => Promise<MemoryRow[]>;
saveSetting: (key: string, value: unknown) => Promise<{ success: boolean; error?: string }>; saveSetting: (key: string, value: unknown) => Promise<{ success: boolean; error?: string }>;
getSetting: <T = unknown>(key: string, defaultValue?: T) => Promise<T>; getSetting: <T = unknown>(key: string, defaultValue?: T) => Promise<T>;
saveToolCall: (tc: unknown) => Promise<{ success: boolean; id?: string; error?: string }>; saveToolCall: (tc: unknown) => Promise<{ success: boolean; id?: string; error?: string }>;