feat: 将记忆系统和知识库合并改造为长效向量记忆系统

- 移除知识库(KB/RAG)功能,删除 kb-modal.ts、rag.ts、memory-panel.ts
- 新增 vector-memory.ts:记忆向量存储与检索引擎
- 新增 memory-modal.ts:大模态框布局的记忆管理面板
- 简化记忆分类为3类:fact(事实)、preference(偏好)、rule(规则)
- 嵌入模型配置移至设置面板
- 无嵌入模型时自动降级为关键词搜索模式
- 向量搜索支持语义相似度检索
- 嵌入模型变化时自动重新索引所有记忆
This commit is contained in:
OpenClaw Agent
2026-04-16 09:27:05 +08:00
parent 759b5fa8db
commit 8ec34106cd
14 changed files with 1108 additions and 1463 deletions
+175 -12
View File
@@ -1,35 +1,41 @@
/**
* MemoryManager - Agent 记忆系统
* MemoryManager - Agent 记忆系统(长效向量记忆)
* 自动提取 → 存储 → 检索 → 注入上下文
*
* 记忆类型:
* - fact: 用户告诉我的事实(项目信息、个人背景等)
* - preference: 用户偏好(语言、风格、格式习惯)
* - rule: 应遵守的规则(命名规范、输出格式要求)
* - episode: 重要事件(完成了什么、得到了什么结论)
*/
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 } from './log-service.js';
import type { MemoryEntry, MemorySearchResult, MemoryExtractionResult, ChatDB, OllamaAPI } from '../types.js';
const TYPE_ICONS: Record<string, string> = {
fact: '📌',
preference: '⚙️',
rule: '📏',
episode: '📝'
rule: '📏'
};
const TYPE_NAMES: Record<string, string> = {
fact: '事实',
preference: '偏好',
rule: '规则',
episode: '事件'
rule: '规则'
};
let memoryCache: MemoryEntry[] = [];
let memoryEnabled = true;
let embeddingModel = '';
// ── 初始化 ──
export async function initMemoryManager(): Promise<void> {
const db = state.get<ChatDB | null>(KEYS.DB);
@@ -38,12 +44,70 @@ export async function initMemoryManager(): Promise<void> {
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);
logMemory(`初始化完成, 加载 ${memoryCache.length}`);
// 如果有嵌入模型,初始化向量存储
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;
}
@@ -67,11 +131,52 @@ 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);
}
// 导出:获取向量搜索结果(供 UI 使用)
export function getVectorSearchResults(query: string): MemorySearchResult[] {
return vectorSearchCache.get(query) || [];
}
// 关键词搜索
function searchMemoriesByKeyword(query: string, limit: number): MemorySearchResult[] {
const queryLower = query.toLowerCase();
const queryWords = queryLower.split(/[\s,,。!?、;:""''()\[\]{}<>`~@#$%^&*+=|\\/.]+/).filter(w => w.length > 1);
@@ -155,6 +260,20 @@ export async function addMemory(data: {
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) {
logWarn('向量存储失败', (err as Error).message);
}
}
return entry;
}
@@ -169,6 +288,21 @@ export async function updateMemory(id: string, updates: Partial<MemoryEntry>): P
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);
}
}
}
// ── 记忆删除 ──
@@ -179,6 +313,17 @@ export async function deleteMemory(id: string): Promise<void> {
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);
}
@@ -190,6 +335,25 @@ export async function clearAllMemories(): Promise<void> {
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);
}
}
}
// ── 标记记忆被使用 ──
@@ -240,13 +404,12 @@ export async function extractMemoriesFromConversation(
sessionTitle?: string
): Promise<number> {
if (!memoryEnabled) return 0;
if (messages.length < 3) 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;
// 取最近 20 条消息作为提取素材
const recentMessages = messages.slice(-20);
const conversationText = recentMessages
.filter(m => m.role === 'user' || m.role === 'assistant')
@@ -277,7 +440,6 @@ ${conversationText.slice(0, 4000)}
- fact: 关于用户的事实(项目、身份、背景、习惯)
- preference: 用户偏好(语言风格、输出格式、技术栈偏好)
- rule: 用户要求遵守的规则(编码规范、输出要求)
- episode: 重要事件(完成的任务、达成的结论)
提取规则:
1. 只提取真正有价值、值得跨会话记住的信息
@@ -308,8 +470,9 @@ ${conversationText.slice(0, 4000)}
const currentSession = state.get(KEYS.CURRENT_SESSION);
for (const entry of parsed.entries) {
if (!entry.content || entry.content.length < 5) continue;
const validType = (['fact', 'preference', 'rule'] as const).includes(entry.type as any) ? entry.type : 'fact';
await addMemory({
type: entry.type || 'fact',
type: validType as any,
content: entry.content,
importance: Math.min(10, Math.max(1, entry.importance || 5)),
tags: entry.tags || [],