v0.13.0: 记忆系统重构 — MEMORY.md 文件化 + 路径保护 + 自动提取优化
核心变更: - 删除 SQLite memories 表 + FTS5 + IVF 向量存储引擎 (~1300行) - 4 个记忆工具合并为 1 个 memory 工具 (5 action) - 记忆存储改为工作空间 MEMORY.md 单文件,严格格式校验 - 路径保护: checkPathAllowed 拦截所有工具,仅 memory 专用 IPC 通道可访问 - 应用启动/工作空间切换时自动校验并初始化 MEMORY.md(格式错误自动备份重建) - 自动记忆提取重建: 对话结束时触发,多层质量过滤(内容/泛化/去重/安全/importance门槛) - 工具总数: 42→40,UI 全面更新(帮助面板、工具面板、设置面板、README) - 版本号更新: 0.12.11 → 0.13.0
This commit is contained in:
@@ -254,7 +254,7 @@ function renderToolCallCard(tc: ToolCallRecord): string {
|
||||
edit_file: '✂️', get_file_info: 'ℹ️', tree: '🌳', download_file: '⬇️',
|
||||
diff_files: '🔀', replace_in_files: '🔄', read_multiple_files: '📚',
|
||||
git: '🔖', compress: '🗜️', web_search: '🔍',
|
||||
memory_search: '🧠', memory_add: '💾', session_list: '📋', session_read: '📖', spawn_task: '🤖', plan_track: '📋',
|
||||
memory: '🧠', session_list: '📋', session_read: '📖', spawn_task: '🤖', plan_track: '📋',
|
||||
browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰',
|
||||
browser_click: '👆', browser_type: '⌨️', browser_scroll: '↕️', browser_close: '❌'
|
||||
};
|
||||
@@ -265,7 +265,7 @@ function renderToolCallCard(tc: ToolCallRecord): string {
|
||||
edit_file: '编辑文件', get_file_info: '文件信息', tree: '目录树', download_file: '下载文件',
|
||||
diff_files: '文件对比', replace_in_files: '批量替换', read_multiple_files: '批量读取',
|
||||
git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索',
|
||||
memory_search: '搜索记忆', memory_add: '添加记忆', session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派', plan_track: '计划追踪',
|
||||
memory: '记忆管理', session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派', plan_track: '计划追踪',
|
||||
browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容',
|
||||
browser_click: '点击元素', browser_type: '输入文本', browser_scroll: '滚动页面', browser_close: '关闭浏览器'
|
||||
};
|
||||
|
||||
@@ -17,7 +17,6 @@ import { ChatDB } from '../db/chat-db.js';
|
||||
import { OllamaAPI } from '../api/ollama.js';
|
||||
import { runAgentLoop } from '../services/agent-engine.js';
|
||||
import { estimateTokens } from '../services/context-manager.js';
|
||||
import { searchMemories, buildMemoryContext, markMemoryUsed, extractMemoriesFromConversation, isMemoryEnabled } from '../services/memory-manager.js';
|
||||
import { showToolConfirm } from './tool-confirm-modal.js';
|
||||
import { logInfo, logStream, logError, logSuccess, logWarn, resetVideoProgress, updateVideoProgress } from '../services/log-service.js';
|
||||
import type { ChatSession, ChatMessage, OllamaStreamChunk, FileContent, ChatFile, ToolCallRecord, AgentMode } from '../types.js';
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
/**
|
||||
* MemoryModal - Agent 记忆管理大模态框
|
||||
* 左右分栏布局:左侧分类筛选+统计,右侧搜索+列表+操作
|
||||
* MemoryModal - Agent 记忆管理(简化版)
|
||||
* 基于工作空间 MEMORY.md 文件
|
||||
*/
|
||||
|
||||
import {
|
||||
getMemoryCache, searchMemories, addMemory, updateMemory, deleteMemory,
|
||||
clearAllMemories, isMemoryEnabled, setMemoryEnabled,
|
||||
getTypeIcon, getTypeName, isVectorMemoryEnabled, getEmbeddingModel
|
||||
} from '../services/memory-manager.js';
|
||||
import { loadAllEntries, addEntry, removeEntry, type MemoryEntry, type MemoryType } from '../services/memory-service.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';
|
||||
import { escapeHtml, formatTime } from '../utils/utils.js';
|
||||
|
||||
let modalEl: HTMLElement;
|
||||
|
||||
// ── 初始化 ──
|
||||
|
||||
export function initMemoryModal(): void {
|
||||
// 创建模态框 DOM
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.id = 'memoryModal';
|
||||
@@ -26,12 +20,9 @@ export function initMemoryModal(): void {
|
||||
overlay.innerHTML = `
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>🧠 Agent 记忆</h3>
|
||||
<h3>🧠 Agent 记忆 (MEMORY.md)</h3>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<label class="toggle-label" title="自动记忆">
|
||||
<input type="checkbox" id="memoryModalToggle" checked>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<button class="btn btn-sm btn-outline" id="btnAddMemoryLg">+ 添加</button>
|
||||
<button class="icon-btn" id="btnCloseMemoryModal">
|
||||
<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"/>
|
||||
@@ -39,65 +30,9 @@ export function initMemoryModal(): void {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="memory-layout">
|
||||
<!-- 左侧边栏 -->
|
||||
<div class="memory-sidebar">
|
||||
<div class="memory-stats" id="memoryStats">
|
||||
<div class="memory-stats-item">
|
||||
<span>总计</span>
|
||||
<span class="memory-stats-count" id="memStatTotal">0</span>
|
||||
</div>
|
||||
<div class="memory-stats-item">
|
||||
<span>📌 事实</span>
|
||||
<span class="memory-stats-count" id="memStatFact">0</span>
|
||||
</div>
|
||||
<div class="memory-stats-item">
|
||||
<span>⚙️ 偏好</span>
|
||||
<span class="memory-stats-count" id="memStatPref">0</span>
|
||||
</div>
|
||||
<div class="memory-stats-item">
|
||||
<span>📏 规则</span>
|
||||
<span class="memory-stats-count" id="memStatRule">0</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="memory-categories" id="memoryCategories">
|
||||
<div class="memory-category-item active" data-type="">
|
||||
<span class="memory-category-icon">📋</span>
|
||||
<span>全部</span>
|
||||
<span class="memory-category-count" id="catCountAll">0</span>
|
||||
</div>
|
||||
<div class="memory-category-item" data-type="fact">
|
||||
<span class="memory-category-icon">📌</span>
|
||||
<span>事实</span>
|
||||
<span class="memory-category-count" id="catCountFact">0</span>
|
||||
</div>
|
||||
<div class="memory-category-item" data-type="preference">
|
||||
<span class="memory-category-icon">⚙️</span>
|
||||
<span>偏好</span>
|
||||
<span class="memory-category-count" id="catCountPref">0</span>
|
||||
</div>
|
||||
<div class="memory-category-item" data-type="rule">
|
||||
<span class="memory-category-icon">📏</span>
|
||||
<span>规则</span>
|
||||
<span class="memory-category-count" id="catCountRule">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 右侧主区域 -->
|
||||
<div class="memory-main">
|
||||
<div class="memory-toolbar">
|
||||
<div class="memory-search-wrap">
|
||||
<input type="text" id="memorySearchLg" class="memory-search-input-lg" placeholder="搜索记忆(支持关键词和语义搜索)...">
|
||||
</div>
|
||||
<span id="memoryVectorBadge" class="memory-vector-badge disabled">关键词模式</span>
|
||||
<button class="btn btn-sm btn-outline" id="btnImportMemoryMd" disabled title="需先在设置中配置嵌入模型">📥 导入文档</button>
|
||||
<button class="btn btn-sm btn-outline" id="btnAddMemoryLg">+ 添加</button>
|
||||
<button class="btn btn-sm btn-danger-outline" id="btnClearMemoryLg">清空</button>
|
||||
</div>
|
||||
<div class="memory-list-lg" id="memoryListLg"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body" style="max-height:65vh;overflow-y:auto;">
|
||||
<div class="memory-list-simple" id="memoryListSimple"></div>
|
||||
<div class="memory-footer-simple" id="memoryFooterSimple" style="text-align:center;padding:16px;color:var(--text-muted);font-size:12px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -105,298 +40,96 @@ export function initMemoryModal(): void {
|
||||
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');
|
||||
// 列表点击:删除按钮
|
||||
overlay.querySelector('#memoryListSimple')!.addEventListener('click', async (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.classList.contains('memory-item-delete')) {
|
||||
const id = target.dataset.id!;
|
||||
const entries = await loadAllEntries();
|
||||
const entry = entries.find(e => e.id === id);
|
||||
if (entry && await showConfirm(`确定删除这条记忆?\n\n${entry.content.slice(0, 100)}`, '删除记忆')) {
|
||||
await removeEntry(entry.content.slice(0, 50));
|
||||
renderList();
|
||||
showToast('记忆已删除', 'info', 1500);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 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 {
|
||||
export async function openMemoryModal(): Promise<void> {
|
||||
modalEl.style.display = '';
|
||||
(modalEl.querySelector('#memoryModalToggle') as HTMLInputElement).checked = isMemoryEnabled();
|
||||
updateVectorBadge();
|
||||
updateStats();
|
||||
renderList();
|
||||
(modalEl.querySelector('#memorySearchLg') as HTMLInputElement).focus();
|
||||
await renderList();
|
||||
}
|
||||
|
||||
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<string, number> = { 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();
|
||||
async function renderList(): Promise<void> {
|
||||
const listEl = modalEl.querySelector('#memoryListSimple') as HTMLElement;
|
||||
const footerEl = modalEl.querySelector('#memoryFooterSimple') as HTMLElement;
|
||||
|
||||
let entries: MemoryEntry[];
|
||||
if (query) {
|
||||
entries = searchMemories(query, 50);
|
||||
} else {
|
||||
entries = getMemoryCache();
|
||||
try {
|
||||
entries = await loadAllEntries();
|
||||
} catch (err) {
|
||||
listEl.innerHTML = `<div style="text-align:center;padding:20px;color:var(--text-muted);">加载失败: ${escapeHtml((err as Error).message)}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
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 = `<div class="memory-empty-lg">${query ? '未找到匹配的记忆' : '暂无记忆,AI 会自动在对话中学习'}</div>`;
|
||||
listEl.innerHTML = `<div style="text-align:center;padding:32px 20px;color:var(--text-muted);">
|
||||
<div style="font-size:48px;margin-bottom:12px;">🧠</div>
|
||||
<div style="font-size:14px;">暂无记忆</div>
|
||||
<div style="font-size:12px;margin-top:8px;color:var(--text-tertiary);">AI 在对话中可通过 memory 工具自动管理记忆<br>工作空间 MEMORY.md 文件存储</div>
|
||||
</div>`;
|
||||
footerEl.textContent = '在对话中让 AI "记住xxx"即可自动添加记忆';
|
||||
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 `
|
||||
<div class="memory-item-lg" data-id="${entry.id}">
|
||||
<div class="memory-item-lg-header">
|
||||
<span>${icon}</span>
|
||||
<span class="memory-item-lg-type">${typeName}</span>
|
||||
<span class="memory-item-lg-importance">${importanceStars}</span>
|
||||
<button class="memory-item-lg-delete" data-id="${entry.id}" title="删除">✕</button>
|
||||
// 按类型分组渲染
|
||||
const typeOrder: MemoryType[] = ['rule', 'preference', 'fact'];
|
||||
const typeIcons: Record<string, string> = { rule: '📏', preference: '⚙️', fact: '📌' };
|
||||
const typeNames: Record<string, string> = { rule: '规则', preference: '偏好', fact: '事实' };
|
||||
|
||||
let html = '';
|
||||
for (const type of typeOrder) {
|
||||
const typeEntries = entries.filter(e => e.type === type);
|
||||
if (typeEntries.length === 0) continue;
|
||||
|
||||
html += `<div style="font-size:12px;font-weight:700;color:var(--text-secondary);padding:8px 0 4px;margin-top:8px;">${typeIcons[type]} ${typeNames[type]} (${typeEntries.length})</div>`;
|
||||
|
||||
for (const entry of typeEntries) {
|
||||
const stars = '★'.repeat(Math.min(entry.importance, 10));
|
||||
html += `<div class="memory-entry-simple" style="padding:10px 14px;margin:4px 0;background:var(--bg-card);border-radius:var(--radius-control);border:1px solid var(--border-subtle);">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;">
|
||||
<div style="flex:1;font-size:13px;line-height:1.5;color:var(--text-primary);">${escapeHtml(entry.content)}</div>
|
||||
<button class="memory-item-delete" data-id="${entry.id}" style="flex-shrink:0;background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:16px;padding:0 4px;line-height:1;" title="删除">✕</button>
|
||||
</div>
|
||||
<div class="memory-item-lg-content">${escapeHtml(entry.content)}</div>
|
||||
<div class="memory-item-lg-meta">
|
||||
${entry.tags.length > 0 ? `<span class="memory-item-lg-tags">${entry.tags.map(t => `<span class="memory-tag-lg">${escapeHtml(t)}</span>`).join('')}</span>` : ''}
|
||||
<span class="memory-item-lg-time">${formatTime(entry.updatedAt)}</span>
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-top:6px;font-size:11px;color:var(--text-tertiary);">
|
||||
<span style="color:var(--accent);">${stars}</span>
|
||||
<span>${entry.id}</span>
|
||||
${entry.tags.length > 0 ? `<span>🏷 ${entry.tags.join(', ')}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).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<void> {
|
||||
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: [] });
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
listEl.innerHTML = html;
|
||||
footerEl.textContent = `共 ${entries.length} 条记忆 · 存储于工作空间 MEMORY.md`;
|
||||
}
|
||||
|
||||
// ── 添加对话框 ──
|
||||
@@ -432,33 +165,11 @@ async function openAddDialog(): Promise<void> {
|
||||
});
|
||||
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<void> {
|
||||
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);
|
||||
try {
|
||||
await addEntry(memoryType, content.trim(), importance);
|
||||
await renderList();
|
||||
showToast('记忆已添加', 'success', 1500);
|
||||
} catch (err) {
|
||||
showToast(`添加失败: ${(err as Error).message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import { state, KEYS } from '../state/state.js';
|
||||
import { debounce } from '../utils/utils.js';
|
||||
import { encryptData, decryptData } from '../services/crypto.js';
|
||||
import { setMemoryEnabled, setEmbeddingModel, getEmbeddingModel, getMemoryCache } from '../services/memory-manager.js';
|
||||
import { logSetting, logInfo, logWarn, logError, logSuccess } from '../services/log-service.js';
|
||||
import { checkConnection, updateConnectionInfo, updateRunningModels } from './header.js';
|
||||
import { loadModels } from './model-bar.js';
|
||||
@@ -224,33 +223,6 @@ 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');
|
||||
logSetting('Agent 记忆', toggleMemory.checked ? '开启' : '关闭');
|
||||
});
|
||||
}
|
||||
|
||||
// ── 向量记忆引擎设置 ──
|
||||
const selectEmbedModel = document.querySelector('#selectMemoryEmbedModel') as HTMLSelectElement;
|
||||
if (selectEmbedModel) {
|
||||
selectEmbedModel.addEventListener('change', async () => {
|
||||
const model = selectEmbedModel.value;
|
||||
await setEmbeddingModel(model);
|
||||
if (model) {
|
||||
showToast(`向量记忆已启用(${model})`, 'success');
|
||||
logSetting('向量记忆引擎', `启用: ${model}`);
|
||||
} else {
|
||||
showToast('向量记忆已关闭,仅使用关键词匹配', 'info');
|
||||
logSetting('向量记忆引擎', '关闭');
|
||||
}
|
||||
updateMemoryVectorStatus();
|
||||
});
|
||||
}
|
||||
|
||||
// ── 工作空间目录设置 ──
|
||||
const inputWorkspaceDir = document.querySelector('#inputWorkspaceDir') as HTMLInputElement;
|
||||
const btnBrowseWorkspace = document.querySelector('#btnBrowseWorkspace') as HTMLButtonElement;
|
||||
@@ -290,6 +262,10 @@ export function initSettingsModal(): void {
|
||||
logSetting('工作空间目录', result.dir!);
|
||||
// ── 通知工作空间面板刷新目录 ──
|
||||
window.dispatchEvent(new CustomEvent('workspaceDirChanged', { detail: { dir: result.dir } }));
|
||||
// ── 初始化新工作空间的 MEMORY.md ──
|
||||
import('../services/memory-service.js').then(({ initMemoryFile }) => {
|
||||
initMemoryFile().catch(() => {});
|
||||
});
|
||||
} else {
|
||||
showToast(`设置失败: ${result.error!}`, 'error');
|
||||
// 恢复原值
|
||||
@@ -305,9 +281,6 @@ export function openSettingsModal(): void {
|
||||
(document.querySelector('#inputServerUrl') as HTMLInputElement).value = state.get<OllamaAPI>(KEYS.API)?.baseUrl || '';
|
||||
updateConnectionInfo();
|
||||
updateRunningModels();
|
||||
populateMemoryEmbedModels();
|
||||
populateSubAgentModels();
|
||||
updateMemoryVectorStatus();
|
||||
loadTimeoutSettings();
|
||||
loadWatchdogSetting();
|
||||
// 刷新工作空间目录显示
|
||||
@@ -413,50 +386,6 @@ async function exportAllSessions(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function populateMemoryEmbedModels(): Promise<void> {
|
||||
const select = document.querySelector('#selectMemoryEmbedModel') as HTMLSelectElement;
|
||||
if (!select) return;
|
||||
const api = state.get<OllamaAPI>(KEYS.API);
|
||||
if (!api) return;
|
||||
|
||||
try {
|
||||
const data = await api.listModels();
|
||||
select.innerHTML = '<option value="">未选择(仅关键词模式)</option>';
|
||||
if (!data.models || data.models.length === 0) return;
|
||||
|
||||
// 获取每个模型的能力信息,筛选嵌入模型
|
||||
const checks = await Promise.allSettled(
|
||||
data.models.map(async (m) => {
|
||||
const info = await api.showModel(m.name);
|
||||
const caps = info.capabilities || [];
|
||||
return { name: m.name, size: m.size, isEmbed: caps.includes('embedding') };
|
||||
})
|
||||
);
|
||||
|
||||
const embedModels = checks
|
||||
.filter(r => r.status === 'fulfilled' && r.value.isEmbed)
|
||||
.map(r => (r as PromiseFulfilledResult<{ name: string; size?: number; isEmbed: boolean }>).value);
|
||||
|
||||
embedModels.forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.name;
|
||||
opt.textContent = m.name;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
|
||||
if (embedModels.length === 0) {
|
||||
select.innerHTML = '<option value="">未检测到嵌入模型,请先 ollama pull</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
// 恢复已保存的选择
|
||||
const saved = getEmbeddingModel();
|
||||
if (saved) select.value = saved;
|
||||
} catch (err) {
|
||||
logWarn('加载嵌入模型列表失败', (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function populateSubAgentModels(): Promise<void> {
|
||||
const select = document.querySelector('#selectSubAgentModel') as HTMLSelectElement;
|
||||
if (!select) return;
|
||||
@@ -533,20 +462,6 @@ const saveLoopWatchdog = debounce(async () => {
|
||||
}, 500);
|
||||
document.querySelector('#inputLoopWatchdog')!.addEventListener('input', saveLoopWatchdog);
|
||||
|
||||
function updateMemoryVectorStatus(): void {
|
||||
const statusEl = document.querySelector('#memoryVectorStatus');
|
||||
if (!statusEl) return;
|
||||
const model = getEmbeddingModel();
|
||||
if (model) {
|
||||
const memCount = getMemoryCache().length;
|
||||
statusEl.textContent = `✅ 向量搜索已启用(${model})· ${memCount} 条记忆`;
|
||||
(statusEl as HTMLElement).style.color = 'var(--success)';
|
||||
} else {
|
||||
statusEl.textContent = 'ℹ️ 未选择嵌入模型,仅使用关键词匹配';
|
||||
(statusEl as HTMLElement).style.color = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function importSessions(filePath: string): Promise<void> {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
if (!db) return;
|
||||
|
||||
@@ -961,7 +961,7 @@ function renderToolCalls(): void {
|
||||
</div>
|
||||
<div class="ws-idle-divider"></div>
|
||||
<div class="ws-idle-desc">AI 对话中自动调用工具,结果在此展示</div>
|
||||
<div class="ws-idle-hint">共 44 个内置工具 · MCP 动态扩展</div>
|
||||
<div class="ws-idle-hint">共 40 个内置工具 · MCP 动态扩展</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
@@ -983,7 +983,7 @@ function getToolDisplayName(name: string): string {
|
||||
edit_file: '编辑文件', get_file_info: '文件信息', tree: '目录树', download_file: '下载文件',
|
||||
diff_files: '文件对比', replace_in_files: '批量替换', read_multiple_files: '批量读取',
|
||||
git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索',
|
||||
memory_search: '搜索记忆', memory_add: '添加记忆', memory_replace: '替换记忆', memory_remove: '删除记忆',
|
||||
memory: '记忆管理',
|
||||
|
||||
session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派', plan_track: '计划追踪',
|
||||
browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容',
|
||||
@@ -1000,7 +1000,7 @@ function getToolIcon(name: string): string {
|
||||
edit_file: '✂️', get_file_info: 'ℹ️', tree: '🌳', download_file: '⬇️',
|
||||
diff_files: '🔀', replace_in_files: '🔄', read_multiple_files: '📚',
|
||||
git: '🔖', compress: '🗜️', web_search: '🔍',
|
||||
memory_search: '🧠', memory_add: '💾', memory_replace: '✏️', memory_remove: '🗑️',
|
||||
memory: '🧠',
|
||||
|
||||
session_list: '📋', session_read: '📖', spawn_task: '🤖', plan_track: '📋',
|
||||
browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰',
|
||||
|
||||
Reference in New Issue
Block a user