feat: Agent 记忆系统替代预设功能,全功能协调运作

核心改造:
- 取消 Agent 预设(保留兼容),新增 AI Agent 记忆系统
- 自动提取 → 存储 → 检索 → 注入上下文的完整记忆闭环
- 记忆类型:事实/偏好/规则/事件,支持重要性评分和标签

新增文件:
- services/memory-manager.ts: 记忆管理核心
  - 关键词+标签+重要性加权检索
  - 会话结束自动提取(LLM 驱动)
  - 去重检测、使用频率追踪
- components/memory-panel.ts: 记忆管理面板 UI

修改文件:
- types.d.ts: 新增 MemoryEntry/MemorySearchResult 类型
- db/chat-db.ts: 升级 v2,新增 memories 存储
- input-area.ts: 对话流集成记忆检索+自动提取
- agent-engine.ts: Agent Loop 注入记忆上下文
- settings-modal.ts: 记忆开关设置
- main.ts: 记忆系统初始化
- index.html: 记忆设置面板 HTML
- style.css: 记忆面板完整样式

功能协调:
- 对话流: 记忆检索 → RAG 检索 → 系统提示词组合 → 流式对话
- Agent Loop: 记忆检索 → 系统提示词注入 → 工具调用循环
- 会话结束: 自动提取关键信息 → 存入记忆库
- 跨会话: 记忆持久化 IndexedDB,新对话自动召回相关记忆
This commit is contained in:
thzxx
2026-04-06 13:44:45 +08:00
parent 37e11502b8
commit dc43db7d9c
10 changed files with 1057 additions and 7 deletions
+34 -1
View File
@@ -14,6 +14,7 @@ import { isRagEnabled, performRagRetrieval } from './kb-modal.js';
import { ChatDB } from '../db/chat-db.js'; 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 { 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 type { ChatSession, ChatMessage, OllamaStreamChunk, RagSource, FileContent, ChatFile, ToolCallRecord } from '../types.js'; import type { ChatSession, ChatMessage, OllamaStreamChunk, RagSource, FileContent, ChatFile, ToolCallRecord } from '../types.js';
@@ -349,6 +350,24 @@ export async function sendMessage(): Promise<void> {
if (systemPromptEnabled && systemPrompt) chatParams.system = systemPrompt; if (systemPromptEnabled && systemPrompt) chatParams.system = systemPrompt;
if (numCtx) chatParams.options = { num_ctx: numCtx, temperature }; if (numCtx) chatParams.options = { num_ctx: numCtx, temperature };
// ── Agent 记忆注入 ──
if (isMemoryEnabled()) {
const userMessage = text || (msgsToAdd.find(m => m.role === 'user')?.content || '');
if (userMessage) {
const relevantMemories = searchMemories(userMessage, 6);
if (relevantMemories.length > 0) {
const memoryContext = buildMemoryContext(relevantMemories);
chatParams.system = chatParams.system
? (chatParams.system as string) + '\n\n' + memoryContext
: memoryContext;
for (const m of relevantMemories) {
await markMemoryUsed(m.id);
}
appendSystemMessage(`🧠 已注入 ${relevantMemories.length} 条相关记忆`, 'memory-status');
}
}
}
let ragSources: RagSource[] | null = null; let ragSources: RagSource[] | null = null;
if (isRagEnabled()) { if (isRagEnabled()) {
@@ -454,8 +473,22 @@ export async function sendMessage(): Promise<void> {
} }
await saveCurrentSession(); await saveCurrentSession();
// ── 自动提取记忆(非阻塞)──
if (isMemoryEnabled() && freshSession.messages.length >= 6) {
const currentSession2 = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
if (currentSession2) {
extractMemoriesFromConversation(
currentSession2.messages.map(m => ({ role: m.role, content: m.content })),
currentSession2.title
).then(count => {
if (count > 0) {
console.log(`[Memory] 自动提取了 ${count} 条记忆`);
}
}).catch(err => console.warn('[Memory] 提取失败:', err));
}
}
} catch (err) { } catch (err) {
console.error('[InputArea] 流式聊天错误:', err);
document.querySelectorAll('.rag-status').forEach(el => el.remove()); document.querySelectorAll('.rag-status').forEach(el => el.remove());
if ((err as Error).name === 'AbortError') { if ((err as Error).name === 'AbortError') {
+237
View File
@@ -0,0 +1,237 @@
/**
* MemoryPanel - Agent 记忆管理面板
* 替代 PresetBar,提供记忆的查看、搜索、编辑、删除功能
*/
import { state, KEYS } from '../state/state.js';
import {
getMemoryCache, searchMemories, addMemory, updateMemory, deleteMemory,
clearAllMemories, isMemoryEnabled, setMemoryEnabled,
getTypeIcon, getTypeName
} from '../services/memory-manager.js';
import { showToast } from './toast.js';
import { debounce, escapeHtml, formatTime } from '../utils/utils.js';
import type { MemoryEntry, MemoryType } from '../types.js';
let panelEl: HTMLElement;
let isOpen = false;
export function initMemoryPanel(): void {
const modelBar = document.querySelector('.model-bar');
if (!modelBar) return;
// 创建记忆状态栏
const bar = document.createElement('div');
bar.className = 'memory-bar';
bar.innerHTML = `
<button class="memory-bar-btn" id="btnMemoryPanel" title="Agent 记忆">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2a7 7 0 0 1 7 7c0 2.38-1.19 4.47-3 5.74V17a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1v-2.26C6.19 13.47 5 11.38 5 9a7 7 0 0 1 7-7z"/>
<line x1="9" y1="21" x2="15" y2="21"/>
</svg>
<span class="memory-bar-label">记忆</span>
<span class="memory-bar-count" id="memoryCount">0</span>
</button>
`;
modelBar.after(bar);
// 创建记忆面板
const panel = document.createElement('div');
panel.className = 'memory-panel';
panel.id = 'memoryPanel';
panel.style.display = 'none';
panel.innerHTML = `
<div class="memory-panel-header">
<h3>🧠 Agent 记忆</h3>
<div class="memory-panel-actions">
<label class="memory-toggle" title="自动记忆">
<input type="checkbox" id="toggleMemoryEnabled" checked>
<span class="toggle-slider-sm"></span>
</label>
<button class="icon-btn sm" id="btnCloseMemoryPanel" title="关闭">
<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"/>
</svg>
</button>
</div>
</div>
<div class="memory-panel-search">
<input type="text" id="memorySearchInput" placeholder="搜索记忆..." class="memory-search-input">
<select id="memoryTypeFilter" class="memory-type-filter">
<option value="">全部类型</option>
<option value="fact">📌 事实</option>
<option value="preference">⚙️ 偏好</option>
<option value="rule">📏 规则</option>
<option value="episode">📝 事件</option>
</select>
</div>
<div class="memory-panel-actions-row">
<button class="btn btn-sm btn-outline" id="btnAddMemory"> 添加记忆</button>
<button class="btn btn-sm btn-danger-outline" id="btnClearMemories">清空</button>
</div>
<div class="memory-list" id="memoryList"></div>
`;
document.querySelector('#app')!.appendChild(panel);
panelEl = panel;
// 绑定事件
bar.querySelector('#btnMemoryPanel')!.addEventListener('click', togglePanel);
panel.querySelector('#btnCloseMemoryPanel')!.addEventListener('click', () => setPanelOpen(false));
const toggleMemory = panel.querySelector('#toggleMemoryEnabled') as HTMLInputElement;
toggleMemory.checked = isMemoryEnabled();
toggleMemory.addEventListener('change', () => {
setMemoryEnabled(toggleMemory.checked);
showToast(toggleMemory.checked ? '自动记忆已开启' : '自动记忆已关闭', 'info');
});
const searchInput = panel.querySelector('#memorySearchInput') as HTMLInputElement;
const typeFilter = panel.querySelector('#memoryTypeFilter') as HTMLSelectElement;
searchInput.addEventListener('input', debounce(() => renderMemoryList(), 200));
typeFilter.addEventListener('change', () => renderMemoryList());
panel.querySelector('#btnAddMemory')!.addEventListener('click', () => openAddMemoryDialog());
panel.querySelector('#btnClearMemories')!.addEventListener('click', async () => {
if (confirm('确定清空所有记忆?此操作不可恢复!')) {
await clearAllMemories();
renderMemoryList();
updateMemoryCount();
showToast('已清空所有记忆', 'success');
}
});
state.on('memoryEntries', () => {
updateMemoryCount();
if (isOpen) renderMemoryList();
});
updateMemoryCount();
}
function togglePanel(): void {
setPanelOpen(!isOpen);
}
function setPanelOpen(open: boolean): void {
isOpen = open;
panelEl.style.display = open ? '' : 'none';
if (open) {
renderMemoryList();
(panelEl.querySelector('#memorySearchInput') as HTMLInputElement)?.focus();
}
}
function updateMemoryCount(): void {
const count = getMemoryCache().length;
const countEl = document.querySelector('#memoryCount');
if (countEl) countEl.textContent = String(count);
}
function renderMemoryList(): void {
const listEl = panelEl.querySelector('#memoryList')!;
const searchInput = panelEl.querySelector('#memorySearchInput') as HTMLInputElement;
const typeFilter = panelEl.querySelector('#memoryTypeFilter') as HTMLSelectElement;
const query = searchInput.value.trim();
const type = typeFilter.value;
let entries: MemoryEntry[];
if (query) {
entries = searchMemories(query, 20).map(r => ({
...r,
score: undefined
}));
} else {
entries = getMemoryCache();
}
if (type) {
entries = entries.filter(e => e.type === type);
}
entries.sort((a, b) => {
if (a.importance !== b.importance) return b.importance - a.importance;
return b.updatedAt - a.updatedAt;
});
if (entries.length === 0) {
listEl.innerHTML = `<div class="memory-empty">${query ? '未找到匹配的记忆' : '暂无记忆,AI 会自动在对话中学习'}</div>`;
return;
}
listEl.innerHTML = entries.map(entry => {
const icon = getTypeIcon(entry.type);
const typeName = getTypeName(entry.type);
const importanceDots = '●'.repeat(Math.min(entry.importance, 10)) + '○'.repeat(Math.max(0, 10 - entry.importance));
return `
<div class="memory-item" data-id="${entry.id}">
<div class="memory-item-header">
<span class="memory-item-icon">${icon}</span>
<span class="memory-item-type">${typeName}</span>
<span class="memory-item-importance" title="重要性 ${entry.importance}/10">${importanceDots}</span>
<button class="memory-item-delete" data-id="${entry.id}" title="删除">✕</button>
</div>
<div class="memory-item-content">${escapeHtml(entry.content)}</div>
<div class="memory-item-meta">
${entry.tags.length > 0 ? `<span class="memory-item-tags">${entry.tags.map(t => `<span class="memory-tag">${escapeHtml(t)}</span>`).join('')}</span>` : ''}
<span class="memory-item-time">${formatTime(entry.updatedAt)}</span>
</div>
</div>
`;
}).join('');
// 绑定删除按钮
listEl.querySelectorAll('.memory-item-delete').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const id = (btn as HTMLElement).dataset.id!;
await deleteMemory(id);
renderMemoryList();
updateMemoryCount();
showToast('记忆已删除', 'info', 1500);
});
});
// 点击编辑
listEl.querySelectorAll('.memory-item-content').forEach(el => {
el.addEventListener('dblclick', () => {
const item = (el as HTMLElement).closest('.memory-item')!;
const id = item.getAttribute('data-id')!;
const entry = getMemoryCache().find(e => e.id === id);
if (entry) openEditMemoryDialog(entry);
});
});
}
function openAddMemoryDialog(): void {
const type = prompt('记忆类型:\n1. fact(事实)\n2. preference(偏好)\n3. rule(规则)\n4. episode(事件)\n\n请输入数字:');
const typeMap: Record<string, MemoryType> = { '1': 'fact', '2': 'preference', '3': 'rule', '4': 'episode' };
const memoryType = typeMap[type || ''] || 'fact';
const content = prompt('请输入记忆内容:');
if (!content?.trim()) return;
const importanceStr = prompt('重要性(1-10,默认 5):');
const importance = Math.min(10, Math.max(1, parseInt(importanceStr || '5') || 5));
addMemory({ type: memoryType, content: content.trim(), importance }).then(() => {
renderMemoryList();
updateMemoryCount();
showToast('记忆已添加', 'success', 1500);
});
}
function openEditMemoryDialog(entry: MemoryEntry): void {
const newContent = prompt('编辑记忆内容:', entry.content);
if (newContent === null || newContent.trim() === entry.content) return;
const importanceStr = prompt('重要性(1-10):', String(entry.importance));
const importance = importanceStr ? Math.min(10, Math.max(1, parseInt(importanceStr) || entry.importance)) : entry.importance;
updateMemory(entry.id, { content: newContent.trim(), importance }).then(() => {
renderMemoryList();
showToast('记忆已更新', 'success', 1500);
});
}
+10
View File
@@ -5,6 +5,7 @@
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, isMetonaFile } from '../services/crypto.js'; import { encryptData, decryptData, isMetonaFile } from '../services/crypto.js';
import { setMemoryEnabled } from '../services/memory-manager.js';
import { updateConnectionInfo, updateRunningModels } from './header.js'; import { updateConnectionInfo, updateRunningModels } from './header.js';
import { loadModels } from './model-bar.js'; import { loadModels } from './model-bar.js';
import { showToast } from './toast.js'; import { showToast } from './toast.js';
@@ -126,6 +127,15 @@ 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');
});
}
} }
export function openSettingsModal(): void { export function openSettingsModal(): void {
+67 -2
View File
@@ -1,15 +1,16 @@
/** /**
* ChatDB - IndexedDB 封装层 * ChatDB - IndexedDB 封装层
* v2: 新增 memories 存储(Agent 记忆系统)
*/ */
import type { ChatSession } from '../types.js'; import type { ChatSession, MemoryEntry } from '../types.js';
export class ChatDB { export class ChatDB {
private dbName: string; private dbName: string;
private version: number; private version: number;
private db: IDBDatabase | null = null; private db: IDBDatabase | null = null;
constructor(dbName = 'metona-ollama', version = 1) { constructor(dbName = 'metona-ollama', version = 2) {
this.dbName = dbName; this.dbName = dbName;
this.version = version; this.version = version;
} }
@@ -36,6 +37,13 @@ export class ChatDB {
if (!db.objectStoreNames.contains('settings')) { if (!db.objectStoreNames.contains('settings')) {
db.createObjectStore('settings', { keyPath: 'key' }); db.createObjectStore('settings', { keyPath: 'key' });
} }
if (!db.objectStoreNames.contains('memories')) {
const memoryStore = db.createObjectStore('memories', { keyPath: 'id' });
memoryStore.createIndex('type', 'type', { unique: false });
memoryStore.createIndex('importance', 'importance', { unique: false });
memoryStore.createIndex('updatedAt', 'updatedAt', { unique: false });
memoryStore.createIndex('lastUsedAt', 'lastUsedAt', { unique: false });
}
}; };
}); });
} }
@@ -151,4 +159,61 @@ export class ChatDB {
request.onerror = () => reject(request.error); request.onerror = () => reject(request.error);
}); });
} }
// ── Agent 记忆系统 CRUD ──
async saveMemory(entry: MemoryEntry): Promise<string> {
return new Promise((resolve, reject) => {
const store = this._tx('memories', 'readwrite');
const request = store.put(entry);
request.onsuccess = () => resolve(entry.id);
request.onerror = () => reject(request.error);
});
}
async getMemory(id: string): Promise<MemoryEntry | null> {
return new Promise((resolve, reject) => {
const store = this._tx('memories');
const request = store.get(id);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(request.error);
});
}
async getAllMemories(): Promise<MemoryEntry[]> {
return new Promise((resolve, reject) => {
const store = this._tx('memories');
const request = store.getAll();
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
}
async getMemoriesByType(type: string): Promise<MemoryEntry[]> {
return new Promise((resolve, reject) => {
const store = this._tx('memories');
const index = store.index('type');
const request = index.getAll(type);
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
}
async deleteMemory(id: string): Promise<void> {
return new Promise((resolve, reject) => {
const store = this._tx('memories', 'readwrite');
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
async clearAllMemories(): Promise<void> {
return new Promise((resolve, reject) => {
const store = this._tx('memories', 'readwrite');
const request = store.clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
} }
+16
View File
@@ -227,6 +227,22 @@
<p class="text-muted" style="font-size:11px;margin-top:8px;">⚠️ 高风险操作(写入/删除/命令执行)会弹出确认对话框。系统关键路径(/etc, /sys, ~/.ssh 等)已自动屏蔽。</p> <p class="text-muted" style="font-size:11px;margin-top:8px;">⚠️ 高风险操作(写入/删除/命令执行)会弹出确认对话框。系统关键路径(/etc, /sys, ~/.ssh 等)已自动屏蔽。</p>
</div> </div>
</div> </div>
<div class="setting-group">
<label class="setting-label">
🧠 Agent 记忆系统
<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-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 class="setting-group"> <div class="setting-group">
<label class="setting-label">显存管理</label> <label class="setting-label">显存管理</label>
<button class="btn btn-danger" id="btnReleaseVRAM">释放显存(卸载模型)</button> <button class="btn btn-danger" id="btnReleaseVRAM">释放显存(卸载模型)</button>
+8
View File
@@ -23,7 +23,9 @@ import { initKBModal } from './components/kb-modal.js';
import { initPresetBar, initPresetModal } from './components/preset-bar.js'; import { initPresetBar, initPresetModal } from './components/preset-bar.js';
import { initPresetManager } from './services/preset-manager.js'; import { initPresetManager } from './services/preset-manager.js';
import { initVectorStore } from './services/rag.js'; import { initVectorStore } from './services/rag.js';
import { initMemoryManager } from './services/memory-manager.js';
import { initToolConfirmModal } from './components/tool-confirm-modal.js'; import { initToolConfirmModal } from './components/tool-confirm-modal.js';
import { initMemoryPanel } from './components/memory-panel.js';
import type { ChatSession } from './types.js'; import type { ChatSession } from './types.js';
function initHelpModal(): void { function initHelpModal(): void {
@@ -157,6 +159,7 @@ async function init(): Promise<void> {
initPresetModal(); initPresetModal();
initHelpModal(); initHelpModal();
initToolConfirmModal(); initToolConfirmModal();
initMemoryPanel();
setupDesktopIntegration(); setupDesktopIntegration();
bindGlobalEvents(); bindGlobalEvents();
@@ -165,6 +168,7 @@ async function init(): Promise<void> {
await loadModels(); await loadModels();
await initVectorStore(); await initVectorStore();
await initPresetManager(); await initPresetManager();
await initMemoryManager();
const savedModel = await db.getSetting('selectedModel', ''); const savedModel = await db.getSetting('selectedModel', '');
if (savedModel) { if (savedModel) {
@@ -200,6 +204,10 @@ async function init(): Promise<void> {
const toggleRC = document.querySelector<HTMLInputElement>('#toggleRunCommand'); const toggleRC = document.querySelector<HTMLInputElement>('#toggleRunCommand');
if (toggleRC) toggleRC.checked = runCommandEnabled; if (toggleRC) toggleRC.checked = runCommandEnabled;
// ── Agent 记忆设置 ──
const memoryEnabled = await db.getSetting('memoryEnabled', true);
state.set('memoryEnabled', memoryEnabled);
(document.querySelector('#chatInput') as HTMLElement)?.focus(); (document.querySelector('#chatInput') as HTMLElement)?.focus();
console.log('[App] 初始化完成'); console.log('[App] 初始化完成');
+18 -2
View File
@@ -10,6 +10,7 @@ import {
getEnabledToolDefinitions, getEnabledToolDefinitions,
needsConfirmation needsConfirmation
} from './tool-registry.js'; } from './tool-registry.js';
import { searchMemories, buildMemoryContext, markMemoryUsed, isMemoryEnabled } from './memory-manager.js';
import { showToast } from '../components/toast.js'; import { showToast } from '../components/toast.js';
import type { import type {
OllamaMessage, OllamaMessage,
@@ -45,13 +46,28 @@ export async function runAgentLoop(
const messages: OllamaMessage[] = []; const messages: OllamaMessage[] = [];
let systemPromptParts: string[] = [];
if (state.get<boolean>(KEYS.SYSTEM_PROMPT_ENABLED)) { if (state.get<boolean>(KEYS.SYSTEM_PROMPT_ENABLED)) {
const systemPrompt = state.get<string>(KEYS.SYSTEM_PROMPT, ''); const systemPrompt = state.get<string>(KEYS.SYSTEM_PROMPT, '');
if (systemPrompt) { if (systemPrompt) systemPromptParts.push(systemPrompt);
messages.push({ role: 'system', content: systemPrompt }); }
// 注入记忆上下文
if (isMemoryEnabled() && userContent) {
const relevantMemories = searchMemories(userContent, 6);
if (relevantMemories.length > 0) {
systemPromptParts.push(buildMemoryContext(relevantMemories));
for (const m of relevantMemories) {
await markMemoryUsed(m.id);
}
} }
} }
if (systemPromptParts.length > 0) {
messages.push({ role: 'system', content: systemPromptParts.join('\n\n') });
}
for (const msg of historyMessages) { for (const msg of historyMessages) {
messages.push({ messages.push({
role: msg.role as 'user' | 'assistant', role: msg.role as 'user' | 'assistant',
+345
View File
@@ -0,0 +1,345 @@
/**
* MemoryManager - Agent 记忆系统
* 自动提取 → 存储 → 检索 → 注入上下文
*
* 记忆类型:
* - fact: 用户告诉我的事实(项目信息、个人背景等)
* - preference: 用户偏好(语言、风格、格式习惯)
* - rule: 应遵守的规则(命名规范、输出格式要求)
* - episode: 重要事件(完成了什么、得到了什么结论)
*/
import { state, KEYS } from '../state/state.js';
import { generateId } from '../utils/utils.js';
import type { MemoryEntry, MemorySearchResult, MemoryExtractionResult, ChatDB, OllamaAPI } from '../types.js';
const TYPE_ICONS: Record<string, string> = {
fact: '📌',
preference: '⚙️',
rule: '📏',
episode: '📝'
};
const TYPE_NAMES: Record<string, string> = {
fact: '事实',
preference: '偏好',
rule: '规则',
episode: '事件'
};
let memoryCache: MemoryEntry[] = [];
let memoryEnabled = true;
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);
memoryCache = await db.getAllMemories();
state.set('memoryEntries', memoryCache);
console.log(`[Memory] 初始化完成,加载 ${memoryCache.length} 条记忆`);
}
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 queryLower = query.toLowerCase();
const queryWords = queryLower.split(/[\s,,。!?、;:""''()\[\]{}<>`~@#$%^&*+=|\\/.]+/).filter(w => w.length > 1);
const scored = memoryCache.map(entry => {
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);
return { ...entry, score };
});
return scored
.filter(s => s.score > 0)
.sort((a, b) => b.score - a.score)
.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);
// 检查重复(内容相似度 > 80% 则跳过)
const existing = memoryCache.find(e => {
if (e.type !== data.type) return false;
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);
return entry;
}
// ── 记忆更新 ──
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]);
}
// ── 记忆删除 ──
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);
}
// ── 清空所有记忆 ──
export async function clearAllMemories(): Promise<void> {
memoryCache = [];
state.set('memoryEntries', []);
const db = state.get<ChatDB | null>(KEYS.DB);
if (db) await db.clearAllMemories();
}
// ── 标记记忆被使用 ──
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 = '【关于用户的重要信息】\n';
context += '以下是你之前记住的关于用户的信息,在回答时请参考:\n\n';
for (const [type, items] of Object.entries(grouped)) {
const typeName = TYPE_NAMES[type] || type;
context += `${typeName}\n`;
for (const item of items) {
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;
// 取最近 20 条消息作为提取素材
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: 用户要求遵守的规则(编码规范、输出要求)
- episode: 重要事件(完成的任务、达成的结论)
提取规则:
1. 只提取真正有价值、值得跨会话记住的信息
2. 每条记忆精炼简洁,不超过 50 字
3. importance 1-105 为普通,8+ 为必须记住
4. tags 2-5 个关键词,用于检索匹配
5. 最多提取 5 条,宁缺毋滥
6. 如果对话中没有值得记住的信息,返回 {"entries": []}`;
try {
const response = await api.chat({
model,
messages: [{ role: 'user', content: extractPrompt }],
stream: false,
think: false,
options: { num_ctx: 8192, temperature: 0.1 }
});
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: 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;
await addMemory({
type: entry.type || 'fact',
content: entry.content,
importance: Math.min(10, Math.max(1, entry.importance || 5)),
tags: entry.tags || [],
source: sessionTitle || '自动提取',
sessionId: currentSession?.id
});
count++;
}
return count;
} catch (err) {
console.warn('[Memory] 自动提取失败:', err);
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;
}
+287
View File
@@ -2394,3 +2394,290 @@ html, body {
overflow-y: auto; overflow-y: auto;
color: rgba(255, 255, 255, 0.75); color: rgba(255, 255, 255, 0.75);
} }
/* ═══════════════════════════════════════════════════════════════
Agent 记忆系统
═══════════════════════════════════════════════════════════════ */
/* ── 记忆状态栏 ── */
.memory-bar {
display: flex;
align-items: center;
padding: 2px 16px;
background: rgba(255, 255, 255, 0.02);
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.memory-bar-btn {
display: flex;
align-items: center;
gap: 6px;
background: none;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: 4px 12px;
color: var(--text-secondary, rgba(255, 255, 255, 0.6));
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
}
.memory-bar-btn:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(0, 245, 212, 0.3);
color: var(--text-primary, #fff);
}
.memory-bar-btn svg {
width: 14px;
height: 14px;
}
.memory-bar-count {
background: rgba(0, 245, 212, 0.15);
color: var(--accent-cyan, #00f5d4);
padding: 0 6px;
border-radius: 8px;
font-size: 11px;
min-width: 18px;
text-align: center;
}
/* ── 记忆面板 ── */
.memory-panel {
position: absolute;
top: 100px;
right: 16px;
width: 380px;
max-height: calc(100vh - 140px);
background: var(--bg-primary, #2d2d2d);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
z-index: 100;
display: flex;
flex-direction: column;
overflow: hidden;
}
.memory-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.memory-panel-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.memory-panel-actions {
display: flex;
align-items: center;
gap: 8px;
}
.memory-toggle {
display: flex;
align-items: center;
cursor: pointer;
}
.memory-toggle input {
display: none;
}
.toggle-slider-sm {
width: 28px;
height: 16px;
background: rgba(255, 255, 255, 0.15);
border-radius: 8px;
position: relative;
transition: background 0.2s;
}
.toggle-slider-sm::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
background: #fff;
border-radius: 50%;
transition: transform 0.2s;
}
.memory-toggle input:checked + .toggle-slider-sm {
background: var(--accent-cyan, #00f5d4);
}
.memory-toggle input:checked + .toggle-slider-sm::after {
transform: translateX(12px);
}
.memory-panel-search {
display: flex;
gap: 8px;
padding: 8px 16px;
}
.memory-search-input {
flex: 1;
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
padding: 6px 10px;
color: var(--text-primary, #fff);
font-size: 12px;
outline: none;
}
.memory-search-input:focus {
border-color: var(--accent-cyan, #00f5d4);
}
.memory-type-filter {
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
padding: 6px 8px;
color: var(--text-primary, #fff);
font-size: 12px;
outline: none;
cursor: pointer;
}
.memory-panel-actions-row {
display: flex;
gap: 8px;
padding: 0 16px 8px;
}
.btn-danger-outline {
background: none;
border: 1px solid rgba(255, 82, 82, 0.3);
color: #ff6b6b;
border-radius: 6px;
padding: 4px 10px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
}
.btn-danger-outline:hover {
background: rgba(255, 82, 82, 0.1);
border-color: rgba(255, 82, 82, 0.5);
}
.memory-list {
flex: 1;
overflow-y: auto;
padding: 0 16px 16px;
}
.memory-empty {
text-align: center;
padding: 24px 16px;
color: var(--text-secondary, rgba(255, 255, 255, 0.4));
font-size: 12px;
}
.memory-item {
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 8px;
padding: 8px 10px;
margin-bottom: 6px;
background: rgba(255, 255, 255, 0.02);
transition: border-color 0.2s;
}
.memory-item:hover {
border-color: rgba(255, 255, 255, 0.12);
}
.memory-item-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.memory-item-icon {
font-size: 12px;
}
.memory-item-type {
font-size: 11px;
color: var(--text-secondary, rgba(255, 255, 255, 0.5));
font-weight: 500;
}
.memory-item-importance {
font-size: 8px;
color: var(--accent-cyan, #00f5d4);
letter-spacing: 1px;
margin-left: auto;
}
.memory-item-delete {
background: none;
border: none;
color: var(--text-secondary, rgba(255, 255, 255, 0.3));
cursor: pointer;
font-size: 12px;
padding: 0 4px;
line-height: 1;
transition: color 0.2s;
}
.memory-item-delete:hover {
color: #ff6b6b;
}
.memory-item-content {
font-size: 12px;
color: var(--text-primary, rgba(255, 255, 255, 0.85));
line-height: 1.5;
cursor: default;
}
.memory-item-meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
flex-wrap: wrap;
}
.memory-item-tags {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.memory-tag {
background: rgba(0, 245, 212, 0.08);
color: var(--accent-cyan, #00f5d4);
padding: 0 6px;
border-radius: 4px;
font-size: 10px;
}
.memory-item-time {
font-size: 10px;
color: var(--text-secondary, rgba(255, 255, 255, 0.3));
margin-left: auto;
}
/* 记忆注入状态 */
.memory-status {
text-align: center;
padding: 4px;
font-size: 11px;
color: var(--accent-cyan, #00f5d4);
opacity: 0.7;
}
+35 -2
View File
@@ -116,7 +116,7 @@ export interface ChatSession {
updatedAt: number; updatedAt: number;
} }
// ── 预设类型 ── // ── 预设类型v3.0 废弃,保留用于兼容)──
export interface Preset { export interface Preset {
id: string; id: string;
@@ -131,6 +131,37 @@ export interface Preset {
updatedAt: number; updatedAt: number;
} }
// ── Agent 记忆系统类型 ──
export type MemoryType = 'fact' | 'preference' | 'rule' | 'episode';
export interface MemoryEntry {
id: string;
type: MemoryType;
content: string;
importance: number; // 1-10,越高越重要
tags: string[]; // 用于快速匹配
source?: string; // 来源描述(如会话标题)
sessionId?: string; // 关联的会话 ID
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 { export interface VectorCollection {
@@ -250,7 +281,9 @@ export type StateKey =
| 'presets' | 'presets'
| 'activePresetId' | 'activePresetId'
| 'toolCallingEnabled' | 'toolCallingEnabled'
| 'runCommandEnabled'; | 'runCommandEnabled'
| 'memoryEnabled'
| 'memoryEntries';
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// Tool Calling 类型 // Tool Calling 类型