From dc43db7d9c80ff69b47761ad924ef980ed643c90 Mon Sep 17 00:00:00 2001 From: thzxx Date: Mon, 6 Apr 2026 13:44:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Agent=20=E8=AE=B0=E5=BF=86=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E6=9B=BF=E4=BB=A3=E9=A2=84=E8=AE=BE=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8C=E5=85=A8=E5=8A=9F=E8=83=BD=E5=8D=8F=E8=B0=83=E8=BF=90?= =?UTF-8?q?=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 核心改造: - 取消 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,新对话自动召回相关记忆 --- src/renderer/components/input-area.ts | 35 ++- src/renderer/components/memory-panel.ts | 237 +++++++++++++++ src/renderer/components/settings-modal.ts | 10 + src/renderer/db/chat-db.ts | 69 ++++- src/renderer/index.html | 16 + src/renderer/main.ts | 8 + src/renderer/services/agent-engine.ts | 20 +- src/renderer/services/memory-manager.ts | 345 ++++++++++++++++++++++ src/renderer/styles/style.css | 287 ++++++++++++++++++ src/renderer/types.d.ts | 37 ++- 10 files changed, 1057 insertions(+), 7 deletions(-) create mode 100644 src/renderer/components/memory-panel.ts create mode 100644 src/renderer/services/memory-manager.ts diff --git a/src/renderer/components/input-area.ts b/src/renderer/components/input-area.ts index 2915769..d137f73 100644 --- a/src/renderer/components/input-area.ts +++ b/src/renderer/components/input-area.ts @@ -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 { 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 { } await saveCurrentSession(); + + // ── 自动提取记忆(非阻塞)── + if (isMemoryEnabled() && freshSession.messages.length >= 6) { + const currentSession2 = state.get(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') { diff --git a/src/renderer/components/memory-panel.ts b/src/renderer/components/memory-panel.ts new file mode 100644 index 0000000..905b287 --- /dev/null +++ b/src/renderer/components/memory-panel.ts @@ -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 = ` + + `; + modelBar.after(bar); + + // 创建记忆面板 + const panel = document.createElement('div'); + panel.className = 'memory-panel'; + panel.id = 'memoryPanel'; + panel.style.display = 'none'; + panel.innerHTML = ` +
+

🧠 Agent 记忆

+
+ + +
+
+ +
+ + +
+
+ `; + + 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 = `
${query ? '未找到匹配的记忆' : '暂无记忆,AI 会自动在对话中学习'}
`; + 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 ` +
+
+ ${icon} + ${typeName} + ${importanceDots} + +
+
${escapeHtml(entry.content)}
+
+ ${entry.tags.length > 0 ? `${entry.tags.map(t => `${escapeHtml(t)}`).join('')}` : ''} + ${formatTime(entry.updatedAt)} +
+
+ `; + }).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 = { '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); + }); +} diff --git a/src/renderer/components/settings-modal.ts b/src/renderer/components/settings-modal.ts index db062de..a4f0d2b 100644 --- a/src/renderer/components/settings-modal.ts +++ b/src/renderer/components/settings-modal.ts @@ -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 { diff --git a/src/renderer/db/chat-db.ts b/src/renderer/db/chat-db.ts index 180a46e..76a8e7f 100644 --- a/src/renderer/db/chat-db.ts +++ b/src/renderer/db/chat-db.ts @@ -1,15 +1,16 @@ /** * ChatDB - IndexedDB 封装层 + * v2: 新增 memories 存储(Agent 记忆系统) */ -import type { ChatSession } from '../types.js'; +import type { ChatSession, MemoryEntry } from '../types.js'; export class ChatDB { private dbName: string; private version: number; private db: IDBDatabase | null = null; - constructor(dbName = 'metona-ollama', version = 1) { + constructor(dbName = 'metona-ollama', version = 2) { this.dbName = dbName; this.version = version; } @@ -36,6 +37,13 @@ export class ChatDB { if (!db.objectStoreNames.contains('settings')) { 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); }); } + + // ── Agent 记忆系统 CRUD ── + + async saveMemory(entry: MemoryEntry): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + return new Promise((resolve, reject) => { + const store = this._tx('memories', 'readwrite'); + const request = store.clear(); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } } diff --git a/src/renderer/index.html b/src/renderer/index.html index 34c10c1..d7e6225 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -227,6 +227,22 @@

⚠️ 高风险操作(写入/删除/命令执行)会弹出确认对话框。系统关键路径(/etc, /sys, ~/.ssh 等)已自动屏蔽。

+
+ +

启用后,AI 会自动从对话中提取关键信息(事实、偏好、规则)并跨会话记住。发送消息时自动检索相关记忆注入上下文。

+
+
📌 事实项目信息、身份背景
+
⚙️ 偏好语言风格、技术栈偏好
+
📏 规则编码规范、输出格式要求
+
📝 事件完成的任务、结论
+
+
diff --git a/src/renderer/main.ts b/src/renderer/main.ts index dfb1251..a07b781 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -23,7 +23,9 @@ import { initKBModal } from './components/kb-modal.js'; import { initPresetBar, initPresetModal } from './components/preset-bar.js'; import { initPresetManager } from './services/preset-manager.js'; import { initVectorStore } from './services/rag.js'; +import { initMemoryManager } from './services/memory-manager.js'; import { initToolConfirmModal } from './components/tool-confirm-modal.js'; +import { initMemoryPanel } from './components/memory-panel.js'; import type { ChatSession } from './types.js'; function initHelpModal(): void { @@ -157,6 +159,7 @@ async function init(): Promise { initPresetModal(); initHelpModal(); initToolConfirmModal(); + initMemoryPanel(); setupDesktopIntegration(); bindGlobalEvents(); @@ -165,6 +168,7 @@ async function init(): Promise { await loadModels(); await initVectorStore(); await initPresetManager(); + await initMemoryManager(); const savedModel = await db.getSetting('selectedModel', ''); if (savedModel) { @@ -200,6 +204,10 @@ async function init(): Promise { const toggleRC = document.querySelector('#toggleRunCommand'); if (toggleRC) toggleRC.checked = runCommandEnabled; + // ── Agent 记忆设置 ── + const memoryEnabled = await db.getSetting('memoryEnabled', true); + state.set('memoryEnabled', memoryEnabled); + (document.querySelector('#chatInput') as HTMLElement)?.focus(); console.log('[App] 初始化完成'); diff --git a/src/renderer/services/agent-engine.ts b/src/renderer/services/agent-engine.ts index 83620eb..e36ad8d 100644 --- a/src/renderer/services/agent-engine.ts +++ b/src/renderer/services/agent-engine.ts @@ -10,6 +10,7 @@ import { getEnabledToolDefinitions, needsConfirmation } from './tool-registry.js'; +import { searchMemories, buildMemoryContext, markMemoryUsed, isMemoryEnabled } from './memory-manager.js'; import { showToast } from '../components/toast.js'; import type { OllamaMessage, @@ -45,13 +46,28 @@ export async function runAgentLoop( const messages: OllamaMessage[] = []; + let systemPromptParts: string[] = []; + if (state.get(KEYS.SYSTEM_PROMPT_ENABLED)) { const systemPrompt = state.get(KEYS.SYSTEM_PROMPT, ''); - if (systemPrompt) { - messages.push({ role: 'system', content: systemPrompt }); + if (systemPrompt) systemPromptParts.push(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) { messages.push({ role: msg.role as 'user' | 'assistant', diff --git a/src/renderer/services/memory-manager.ts b/src/renderer/services/memory-manager.ts new file mode 100644 index 0000000..8b4f537 --- /dev/null +++ b/src/renderer/services/memory-manager.ts @@ -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 = { + fact: '📌', + preference: '⚙️', + rule: '📏', + episode: '📝' +}; + +const TYPE_NAMES: Record = { + fact: '事实', + preference: '偏好', + rule: '规则', + episode: '事件' +}; + +let memoryCache: MemoryEntry[] = []; +let memoryEnabled = true; + +export async function initMemoryManager(): Promise { + const db = state.get(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(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 { + const db = state.get(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): Promise { + 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(KEYS.DB); + if (db) await db.saveMemory(memoryCache[idx]); +} + +// ── 记忆删除 ── + +export async function deleteMemory(id: string): Promise { + memoryCache = memoryCache.filter(e => e.id !== id); + state.set('memoryEntries', [...memoryCache]); + + const db = state.get(KEYS.DB); + if (db) await db.deleteMemory(id); +} + +// ── 清空所有记忆 ── + +export async function clearAllMemories(): Promise { + memoryCache = []; + state.set('memoryEntries', []); + + const db = state.get(KEYS.DB); + if (db) await db.clearAllMemories(); +} + +// ── 标记记忆被使用 ── + +export async function markMemoryUsed(id: string): Promise { + const entry = memoryCache.find(e => e.id === id); + if (!entry) return; + + entry.useCount++; + entry.lastUsedAt = Date.now(); + + const db = state.get(KEYS.DB); + if (db) await db.saveMemory(entry); +} + +// ── 构建记忆上下文(注入 system prompt)── + +export function buildMemoryContext(memories: MemorySearchResult[]): string { + if (memories.length === 0) return ''; + + const grouped: Record = {}; + 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 { + if (!memoryEnabled) return 0; + if (messages.length < 3) return 0; // 至少一轮完整对话 + + const api = state.get(KEYS.API); + const model = state.get('_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-10,5 为普通,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; +} diff --git a/src/renderer/styles/style.css b/src/renderer/styles/style.css index fe5959b..6074825 100644 --- a/src/renderer/styles/style.css +++ b/src/renderer/styles/style.css @@ -2394,3 +2394,290 @@ html, body { overflow-y: auto; 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; +} diff --git a/src/renderer/types.d.ts b/src/renderer/types.d.ts index 68180fb..b5dfd38 100644 --- a/src/renderer/types.d.ts +++ b/src/renderer/types.d.ts @@ -116,7 +116,7 @@ export interface ChatSession { updatedAt: number; } -// ── 预设类型 ── +// ── 预设类型(v3.0 废弃,保留用于兼容)── export interface Preset { id: string; @@ -131,6 +131,37 @@ export interface Preset { 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 { @@ -250,7 +281,9 @@ export type StateKey = | 'presets' | 'activePresetId' | 'toolCallingEnabled' - | 'runCommandEnabled'; + | 'runCommandEnabled' + | 'memoryEnabled' + | 'memoryEntries'; // ═══════════════════════════════════════════════════════════ // Tool Calling 类型