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:
@@ -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 API(70+引擎聚合)/ 四引擎 HTML 解析(Bing+百度+搜狗+360),双模式可切换;web_fetch 支持反爬+UA切换+浏览器回退 |
|
| 🔍 | **联网搜索(双模式)** | SearXNG 元搜索引擎 JSON API(70+引擎聚合)/ 四引擎 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 个 action:search 搜索 / 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
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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'),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 自己的工作空间),放行
|
||||||
|
|||||||
@@ -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: '关闭浏览器'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -3,7 +3,7 @@
|
|||||||
* v4.0: 桌面端走 IPC → SQLite,Web 端仍用 IndexedDB
|
* v4.0: 桌面端走 IPC → SQLite,Web 端仍用 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
@@ -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 个):git(17 个子操作,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 个):git(17 个子操作,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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 规则)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 重要约束
|
## 重要约束
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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: '会话操作' },
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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: '提取内容',
|
||||||
|
|||||||
@@ -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} 条`);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
+5
-86
@@ -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 }>;
|
||||||
|
|||||||
Reference in New Issue
Block a user