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 || [],
-152
View File
@@ -1,152 +0,0 @@
/**
* RAG - 检索增强生成管线
*/
import { state, KEYS } from '../state/state.js';
import { VectorStore } from './vector-store.js';
import { chunkText, createChunkMetadata } from './document-processor.js';
import { logRAG, logDebug, logError } from './log-service.js';
import type { OllamaAPI, SearchResult, VectorItem } from '../types.js';
let vectorStore: VectorStore | null = null;
export async function initVectorStore(): Promise<VectorStore> {
if (vectorStore) return vectorStore;
vectorStore = new VectorStore();
await vectorStore.init();
logRAG('向量存储已初始化');
return vectorStore;
}
export function getVectorStore(): VectorStore | null {
return vectorStore;
}
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 embedBatch(texts: string[], model: string, onProgress?: (done: number, total: number) => void): Promise<number[][]> {
const results: number[][] = [];
const batchSize = 5;
logDebug(`嵌入批次`, `${texts.length} 个文本, 模型: ${model}`);
for (let i = 0; i < texts.length; i += batchSize) {
const batch = texts.slice(i, i + batchSize);
const embeddings = await Promise.all(batch.map(t => embedText(t, model)));
results.push(...embeddings);
if (onProgress) onProgress(Math.min(i + batchSize, texts.length), texts.length);
}
return results;
}
export async function addDocumentToKB(
colId: string, filename: string, content: string, embedModel: string,
onProgress?: (done: number, total: number, msg: string) => void
): Promise<{ docId: string; chunkCount: number }> {
const vs = await initVectorStore();
const textChunks = chunkText(content);
if (textChunks.length === 0) throw new Error('文档内容为空,无法处理');
logRAG(`分块完成: ${filename}`, `${textChunks.length} 个分块`);
const docId = `doc_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
if (onProgress) onProgress(0, textChunks.length, '正在生成向量...');
const embeddings = await embedBatch(textChunks, embedModel, (done, total) => {
if (onProgress) onProgress(done, total, '正在生成向量...');
});
const vectors: VectorItem[] = textChunks.map((chunk, i) => {
const meta = createChunkMetadata(chunk, i, docId, filename);
return {
...meta,
collectionId: colId,
embedding: embeddings[i]
};
});
if (onProgress) onProgress(textChunks.length, textChunks.length, '正在保存...');
await vs.addVectors(vectors);
const col = await vs.getCollection(colId);
if (col) {
col.docCount = (col.docCount || 0) + 1;
col.chunkCount = (col.chunkCount || 0) + textChunks.length;
col.embeddingModel = embedModel;
await vs.updateCollection(col);
}
return { docId, chunkCount: textChunks.length };
}
export async function removeDocumentFromKB(colId: string, docId: string): Promise<void> {
const vs = await initVectorStore();
const allVectors = await vs.getVectorsByCollection(colId);
const docVectors = allVectors.filter(v => v.docId === docId);
logRAG(`删除文档`, `${docId} (${docVectors.length} 个分块)`);
await vs.deleteVectorsByDocument(colId, docId);
const col = await vs.getCollection(colId);
if (col) {
col.chunkCount = Math.max(0, (col.chunkCount || 0) - docVectors.length);
col.docCount = Math.max(0, (col.docCount || 0) - 1);
await vs.updateCollection(col);
}
}
export async function getDocumentsInCollection(colId: string): Promise<Array<{ docId: string; filename: string; chunkCount: number }>> {
const vs = await initVectorStore();
const vectors = await vs.getVectorsByCollection(colId);
const docMap = new Map<string, { docId: string; filename: string; chunkCount: number }>();
for (const v of vectors) {
if (!docMap.has(v.docId)) {
docMap.set(v.docId, { docId: v.docId, filename: v.filename, chunkCount: 0 });
}
docMap.get(v.docId)!.chunkCount++;
}
return Array.from(docMap.values());
}
export async function retrieveContext(query: string, colId: string, topK = 5): Promise<{ context: string; results: SearchResult[] }> {
const vs = await initVectorStore();
const col = await vs.getCollection(colId);
if (!col) throw new Error('知识库集合不存在');
const embedModel = col.embeddingModel;
if (!embedModel) throw new Error('未配置嵌入模型');
logRAG('开始检索', col.name);
const queryEmbedding = await embedText(query, embedModel);
const results = await vs.search(colId, queryEmbedding, topK);
if (results.length === 0) return { context: '', results: [] };
logRAG(`检索完成`, `${results.length} 个相关片段`);
const contextParts = results.map((r, i) =>
`[来源 ${i + 1}: ${r.filename}]\n${r.text}`
);
const context = contextParts.join('\n\n---\n\n');
return { context, results };
}
export function buildRagSystemPrompt(context: string, originalSystemPrompt = ''): string {
const ragPrompt = `你是一个知识库问答助手。以下是检索到的相关文档片段,请基于这些内容回答用户的问题。如果检索到的内容无法回答问题,请如实说明。
=== 检索到的相关内容 ===
${context}
=== 内容结束 ===
请基于以上内容回答用户的问题。引用时请注明来源编号(如"来源 1")。`;
if (originalSystemPrompt) {
return originalSystemPrompt + '\n\n' + ragPrompt;
}
return ragPrompt;
}
+171
View File
@@ -0,0 +1,171 @@
/**
* 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, OllamaAPI, VectorItem, SearchResult } from '../types.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 -1
View File
@@ -54,7 +54,7 @@ export class VectorStore {
async createCollection(name: string, embeddingModel = ''): Promise<VectorCollection> {
const col: VectorCollection = {
id: `kb_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
id: `mem_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
name, embeddingModel,
docCount: 0, chunkCount: 0,
createdAt: Date.now(), updatedAt: Date.now()