/**
* MemoryModal - Agent 记忆管理大模态框
* 左右分栏布局:左侧分类筛选+统计,右侧搜索+列表+操作
*/
import {
getMemoryCache, searchMemories, addMemory, updateMemory, deleteMemory,
clearAllMemories, isMemoryEnabled, setMemoryEnabled,
getTypeIcon, getTypeName, isVectorMemoryEnabled, getEmbeddingModel
} from '../services/memory-manager.js';
import { showToast } from './toast.js';
import { showPrompt, showConfirm } from './prompt-modal.js';
import { debounce, escapeHtml, formatTime } from '../utils/utils.js';
import type { MemoryEntry, MemoryType } from '../types.js';
let modalEl: HTMLElement;
// ── 初始化 ──
export function initMemoryModal(): void {
// 创建模态框 DOM
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.id = 'memoryModal';
overlay.style.display = 'none';
overlay.innerHTML = `
`;
document.querySelector('#app')!.appendChild(overlay);
modalEl = overlay;
// 绑定事件
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeMemoryModal();
});
overlay.querySelector('#btnCloseMemoryModal')!.addEventListener('click', closeMemoryModal);
// 自动记忆开关
const toggle = overlay.querySelector('#memoryModalToggle') as HTMLInputElement;
toggle.checked = isMemoryEnabled();
toggle.addEventListener('change', () => {
setMemoryEnabled(toggle.checked);
showToast(toggle.checked ? '自动记忆已开启' : '自动记忆已关闭', 'info');
});
// 搜索
const searchInput = overlay.querySelector('#memorySearchLg') as HTMLInputElement;
searchInput.addEventListener('input', debounce(() => renderList(), 300));
// 分类筛选
overlay.querySelectorAll('.memory-category-item').forEach(el => {
el.addEventListener('click', () => {
overlay.querySelectorAll('.memory-category-item').forEach(c => c.classList.remove('active'));
el.classList.add('active');
renderList();
});
});
// 添加记忆
overlay.querySelector('#btnAddMemoryLg')!.addEventListener('click', openAddDialog);
// 导入 Markdown 记忆文档
overlay.querySelector('#btnImportMemoryMd')!.addEventListener('click', importMemoryFromMd);
// 清空
overlay.querySelector('#btnClearMemoryLg')!.addEventListener('click', async () => {
if (await showConfirm('确定清空所有记忆?此操作不可恢复!', '清空记忆')) {
await clearAllMemories();
renderList();
updateStats();
showToast('已清空所有记忆', 'success');
}
});
// header 按钮
document.querySelector('#btnMemory')?.addEventListener('click', openMemoryModal);
// 全局状态监听
if (typeof (window as unknown as { __memoryStateListener?: boolean }).__memoryStateListener === 'undefined') {
(window as unknown as { __memoryStateListener?: boolean }).__memoryStateListener = true;
// 简单轮询:在模态框打开时监听变化
}
}
// ── 打开/关闭 ──
export function openMemoryModal(): void {
modalEl.style.display = '';
(modalEl.querySelector('#memoryModalToggle') as HTMLInputElement).checked = isMemoryEnabled();
updateVectorBadge();
updateStats();
renderList();
(modalEl.querySelector('#memorySearchLg') as HTMLInputElement).focus();
}
export function closeMemoryModal(): void {
modalEl.style.display = 'none';
}
// ── 向量记忆状态 ──
function updateVectorBadge(): void {
const badge = modalEl.querySelector('#memoryVectorBadge')!;
const importBtn = modalEl.querySelector('#btnImportMemoryMd') as HTMLButtonElement;
if (isVectorMemoryEnabled()) {
badge.textContent = `✅ 向量搜索 (${getEmbeddingModel()})`;
badge.className = 'memory-vector-badge enabled';
importBtn.disabled = false;
importBtn.title = '导入 Markdown 文档(自动向量化)';
} else {
badge.textContent = '关键词模式';
badge.className = 'memory-vector-badge disabled';
importBtn.disabled = true;
importBtn.title = '需先在设置中配置嵌入模型';
}
}
// ── 统计 ──
function updateStats(): void {
const entries = getMemoryCache();
const counts: Record = { total: entries.length, fact: 0, preference: 0, rule: 0 };
for (const e of entries) {
if (e.type in counts) counts[e.type]++;
}
const el = (id: string) => modalEl.querySelector(`#${id}`)!;
el('memStatTotal').textContent = String(counts.total);
el('memStatFact').textContent = String(counts.fact);
el('memStatPref').textContent = String(counts.preference);
el('memStatRule').textContent = String(counts.rule);
el('catCountAll').textContent = String(counts.total);
el('catCountFact').textContent = String(counts.fact);
el('catCountPref').textContent = String(counts.preference);
el('catCountRule').textContent = String(counts.rule);
}
// ── 列表渲染 ──
function getActiveType(): string {
const active = modalEl.querySelector('.memory-category-item.active') as HTMLElement;
return active?.dataset.type || '';
}
function renderList(): void {
const listEl = modalEl.querySelector('#memoryListLg')!;
const query = (modalEl.querySelector('#memorySearchLg') as HTMLInputElement).value.trim();
const typeFilter = getActiveType();
let entries: MemoryEntry[];
if (query) {
entries = searchMemories(query, 50);
} else {
entries = getMemoryCache();
}
if (typeFilter) {
entries = entries.filter(e => e.type === typeFilter);
}
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 importanceStars = '★'.repeat(Math.min(entry.importance, 10)) + '☆'.repeat(Math.max(0, 10 - entry.importance));
return `
${escapeHtml(entry.content)}
${entry.tags.length > 0 ? `${entry.tags.map(t => `${escapeHtml(t)}`).join('')}` : ''}
${formatTime(entry.updatedAt)}
`;
}).join('');
// 删除按钮
listEl.querySelectorAll('.memory-item-lg-delete').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const id = (btn as HTMLElement).dataset.id!;
await deleteMemory(id);
renderList();
updateStats();
showToast('记忆已删除', 'info', 1500);
});
});
// 双击编辑
listEl.querySelectorAll('.memory-item-lg-content').forEach(el => {
el.addEventListener('dblclick', () => {
const item = (el as HTMLElement).closest('.memory-item-lg')!;
const id = item.getAttribute('data-id')!;
const entry = getMemoryCache().find(e => e.id === id);
if (entry) openEditDialog(entry);
});
});
}
// ── 导入 Markdown 记忆文档 ──
async function importMemoryFromMd(): Promise {
if (!isVectorMemoryEnabled()) {
showToast('请先在设置中配置嵌入模型以启用向量记忆', 'warning');
return;
}
const bridge = window.metonaDesktop;
if (!bridge || !bridge.isDesktop) {
showToast('导入功能仅在桌面端可用', 'warning');
return;
}
const paths = await bridge.dialog.openFile({
filters: [{ name: 'Markdown 文档', extensions: ['md'] }]
});
if (!paths || paths.length === 0) return;
const filePath = paths[0];
const fileName = filePath.split(/[/\\]/).pop() || filePath;
try {
const result = await bridge.fs.readFile(filePath);
if (!result.success) {
showToast(`读取失败: ${result.error}`, 'error');
return;
}
const content = result.content as string;
const sections = parseMdToMemories(content);
if (sections.length === 0) {
showToast('未从文档中解析到有效记忆内容', 'warning');
return;
}
let imported = 0;
let skipped = 0;
for (const sec of sections) {
if (!sec.content || sec.content.length < 5) { skipped++; continue; }
await addMemory({
type: sec.type || 'fact',
content: sec.content,
importance: sec.importance || 5,
tags: sec.tags || [],
source: fileName
});
imported++;
}
renderList();
updateStats();
showToast(`已导入 ${imported} 条记忆(已向量化)${skipped > 0 ? `,跳过 ${skipped} 条` : ''}`, 'success', 3000);
} catch (err) {
showToast(`导入失败: ${(err as Error).message}`, 'error');
}
}
function parseMdToMemories(md: string): Array<{ type: MemoryType; content: string; importance: number; tags: string[] }> {
const results: Array<{ type: MemoryType; content: string; importance: number; tags: string[] }> = [];
// 按 ## 二级标题分块
const blocks = md.split(/^## /m).filter(b => b.trim());
for (const block of blocks) {
const lines = block.trim().split('\n');
const heading = lines[0]?.trim() || '';
const body = lines.slice(1).join('\n').trim();
if (!body && !heading) continue;
// 从标题推断类型
let type: MemoryType = 'fact';
const headingLower = heading.toLowerCase();
if (headingLower.includes('偏好') || headingLower.includes('preference') || headingLower.includes('风格') || headingLower.includes('习惯')) {
type = 'preference';
} else if (headingLower.includes('规则') || headingLower.includes('rule') || headingLower.includes('规范') || headingLower.includes('约束')) {
type = 'rule';
}
// 提取标签:标题中的关键词 + 正文前几个有意义的词
const tags: string[] = [];
if (heading) {
const headingTags = heading.replace(/[^\w\u4e00-\u9fff\s]/g, ' ').split(/\s+/).filter(w => w.length > 1);
tags.push(...headingTags.slice(0, 3));
}
// 内容:标题 + 正文(如果正文为空用标题作内容)
const content = body ? `${heading ? heading + ':' : ''}${body}` : heading;
if (content.length < 5) continue;
// 从标题提取重要性(如 "重要性:8" 或 "importance: 7")
let importance = 5;
const impMatch = heading.match(/(?:重要性|importance)\s*[::]\s*(\d+)/i);
if (impMatch) importance = Math.min(10, Math.max(1, parseInt(impMatch[1]) || 5));
results.push({ type, content: content.slice(0, 500), importance, tags });
}
// 如果按标题分块无结果,按段落分块
if (results.length === 0) {
const paragraphs = md.split(/\n{2,}/).filter(p => p.trim());
for (const para of paragraphs) {
const text = para.trim().replace(/^[-*]\s+/gm, '').replace(/^#+\s+/gm, '');
if (text.length < 10) continue;
results.push({ type: 'fact', content: text.slice(0, 500), importance: 5, tags: [] });
}
}
return results;
}
// ── 添加对话框 ──
async function openAddDialog(): Promise {
const type = await showPrompt({
title: '添加记忆',
message: '选择记忆类型:',
type: 'select',
defaultValue: 'fact',
options: [
{ value: 'fact', label: '📌 事实 — 项目信息、身份背景' },
{ value: 'preference', label: '⚙️ 偏好 — 语言风格、技术栈偏好' },
{ value: 'rule', label: '📏 规则 — 编码规范、输出要求' }
]
});
if (type === null) return;
const memoryType = (type as MemoryType) || 'fact';
const content = await showPrompt({
title: '添加记忆',
message: '请输入记忆内容:',
type: 'textarea',
placeholder: '例如:用户正在开发一个 Electron 桌面应用'
});
if (!content?.trim()) return;
const importanceStr = await showPrompt({
title: '添加记忆',
message: '重要性(1-10,默认 5):',
defaultValue: '5',
placeholder: '5'
});
const importance = Math.min(10, Math.max(1, parseInt(importanceStr || '5') || 5));
await addMemory({ type: memoryType, content: content.trim(), importance });
renderList();
updateStats();
showToast('记忆已添加', 'success', 1500);
}
// ── 编辑对话框 ──
async function openEditDialog(entry: MemoryEntry): Promise {
const newContent = await showPrompt({
title: '编辑记忆',
message: '修改记忆内容:',
type: 'textarea',
defaultValue: entry.content
});
if (newContent === null || newContent.trim() === entry.content) return;
const importanceStr = await showPrompt({
title: '编辑记忆',
message: '重要性(1-10):',
defaultValue: String(entry.importance),
placeholder: String(entry.importance)
});
const importance = importanceStr ? Math.min(10, Math.max(1, parseInt(importanceStr) || entry.importance)) : entry.importance;
await updateMemory(entry.id, { content: newContent.trim(), importance });
renderList();
updateStats();
showToast('记忆已更新', 'success', 1500);
}