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 { 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') {
|
||||||
|
|||||||
@@ -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 { 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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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] 初始化完成');
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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-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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+35
-2
@@ -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 类型
|
||||||
|
|||||||
Reference in New Issue
Block a user