Files
metona-ollama-desktop/src/renderer/components/memory-modal.ts
T
紫影233 5f9acebad1 fix: 记忆去重时明确告知 AI「已存在,不要再重复添加」
之前 addEntry 检测到重复后静默返回已有条目(success),AI 误以为又成功保存了
新条目,于是继续循环调用。现在改为抛出 DuplicateEntryError,tool handler
捕获后返回 {success:true, duplicate:true, message:'已跳过...不要再重复添加'},
明确告诉 AI 任务已完成。

也处理了内存面板的手动添加场景,友好提示用户。
2026-06-24 15:31:58 +08:00

180 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* MemoryModal - Agent 记忆管理(简化版)
* 基于工作空间 MEMORY.md 文件
*/
import { loadAllEntries, addEntry, removeEntry, DuplicateEntryError, type MemoryEntry, type MemoryType } from '../services/memory-service.js';
import { showToast } from './toast.js';
import { showPrompt, showConfirm } from './prompt-modal.js';
import { escapeHtml, formatTime } from '../utils/utils.js';
let modalEl: HTMLElement;
// ── 初始化 ──
export function initMemoryModal(): void {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.id = 'memoryModal';
overlay.style.display = 'none';
overlay.innerHTML = `
<div class="modal">
<div class="modal-header">
<h3>🧠 Agent 记忆 (MEMORY.md)</h3>
<div style="display:flex;align-items:center;gap:8px;">
<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"/>
</svg>
</button>
</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>
`;
document.querySelector('#app')!.appendChild(overlay);
modalEl = overlay;
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeMemoryModal();
});
overlay.querySelector('#btnCloseMemoryModal')!.addEventListener('click', closeMemoryModal);
overlay.querySelector('#btnAddMemoryLg')!.addEventListener('click', openAddDialog);
// 列表点击:删除按钮
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);
}
// ── 打开/关闭 ──
export async function openMemoryModal(): Promise<void> {
modalEl.style.display = '';
await renderList();
}
export function closeMemoryModal(): void {
modalEl.style.display = 'none';
}
// ── 列表渲染 ──
async function renderList(): Promise<void> {
const listEl = modalEl.querySelector('#memoryListSimple') as HTMLElement;
const footerEl = modalEl.querySelector('#memoryFooterSimple') as HTMLElement;
let entries: MemoryEntry[];
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 (entries.length === 0) {
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;
}
// 按类型分组渲染
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 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>`;
}
}
listEl.innerHTML = html;
footerEl.textContent = `共 ${entries.length} 条记忆 · 存储于工作空间 MEMORY.md`;
}
// ── 添加对话框 ──
async function openAddDialog(): Promise<void> {
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));
try {
await addEntry(memoryType, content.trim(), importance);
await renderList();
showToast('记忆已添加', 'success', 1500);
} catch (err) {
if (err instanceof DuplicateEntryError) {
showToast('该记忆已存在,无需重复添加', 'info', 2000);
} else {
showToast(`添加失败: ${(err as Error).message}`, 'error');
}
}
}