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:
@@ -14,6 +14,7 @@ import { isRagEnabled, performRagRetrieval } from './kb-modal.js';
|
||||
import { ChatDB } from '../db/chat-db.js';
|
||||
import { OllamaAPI } from '../api/ollama.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 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 (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;
|
||||
|
||||
if (isRagEnabled()) {
|
||||
@@ -454,8 +473,22 @@ export async function sendMessage(): Promise<void> {
|
||||
}
|
||||
|
||||
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) {
|
||||
console.error('[InputArea] 流式聊天错误:', err);
|
||||
document.querySelectorAll('.rag-status').forEach(el => el.remove());
|
||||
|
||||
if ((err as Error).name === 'AbortError') {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
import { state, KEYS } from '../state/state.js';
|
||||
import { debounce } from '../utils/utils.js';
|
||||
import { encryptData, decryptData, isMetonaFile } from '../services/crypto.js';
|
||||
import { setMemoryEnabled } from '../services/memory-manager.js';
|
||||
import { updateConnectionInfo, updateRunningModels } from './header.js';
|
||||
import { loadModels } from './model-bar.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 {
|
||||
|
||||
Reference in New Issue
Block a user