/** * MemoryViewer — 记忆浏览器 * * 列出所有记忆(episodic/semantic/working),支持搜索、删除。 * Agent 完成任务后自动刷新(监听 agentStatus: thinking|executing -> idle)。 */ import { useState, useEffect, useRef, useCallback } from 'react'; import { Box, Typography, Stack, Accordion, AccordionSummary, AccordionDetails, IconButton, TextField, Chip, Alert, InputAdornment, } from '@mui/material'; import { Brain, Search, Trash2, ChevronDown } from 'lucide-react'; import { useAgentStore } from '@renderer/stores/agent-store'; import { useUIStore } from '@renderer/stores/ui-store'; import { formatTime, truncate } from '@renderer/lib/formatters'; // ===== 类型 ===== type MemoryType = 'episodic' | 'semantic' | 'working'; interface MemoryItem { id: string; type: MemoryType; content: string; summary?: string; importance?: number; createdAt?: number; created_at?: number; } interface SearchResult { id: string; type: MemoryType; content: string; summary?: string; importance: number; relevanceScore: number; createdAt: number; } // ===== 常量 ===== const MEMORY_TYPES: MemoryType[] = ['episodic', 'semantic', 'working']; const MEMORY_TYPE_LABELS: Record = { episodic: '情景记忆', semantic: '语义记忆', working: '工作记忆', }; const MEMORY_TYPE_COLORS: Record = { episodic: '#22d3ee', semantic: '#a855f7', working: '#fbbf24', }; function importanceColor(score: number): string { if (score >= 0.8) return '#ef4444'; if (score >= 0.5) return '#f59e0b'; return '#64748b'; } function getCreatedAt(item: MemoryItem): number { return item.createdAt ?? item.created_at ?? 0; } function getId(item: MemoryItem): string { return item.id; } // ===== 主组件 ===== export function MemoryViewer(): React.JSX.Element { const [memories, setMemories] = useState>({ episodic: [], semantic: [], working: [], }); const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState(null); const [error, setError] = useState(null); const [searching, setSearching] = useState(false); const [expanded, setExpanded] = useState('all'); // Agent 完成时自动刷新:监听 agentStatus 从 thinking/executing -> idle const agentStatus = useAgentStore((s) => s.agentStatus); const prevStatus = useRef(agentStatus); // v0.3.6: 监听 memoryVersion 变化(SettingsModal 清理记忆后自增),触发重新加载 const memoryVersion = useUIStore((s) => s.memoryVersion); // M-53 修复: 竞态保护 ref,防止多次 loadMemories 调用乱序完成导致旧数据覆盖 const loadReqIdRef = useRef(0); // 审计补充修复: handleSearch 使用独立 ref,避免与 loadMemories 共用导致 searching 状态卡死 // 原问题:handleSearch 与 loadMemories 共用 loadReqIdRef,当 agent 完成触发 loadMemories 时 // 会 ++ref,使 handleSearch 的 finally 检查失败,setSearching(false) 不执行,UI 永久显示"搜索中..." const searchReqIdRef = useRef(0); const loadMemories = useCallback(async () => { if (!window.metona?.memory?.listAll) return; const reqId = ++loadReqIdRef.current; try { setError(null); const res = await window.metona.memory.listAll(); // 竞态保护:若已被新请求取代或组件已卸载,放弃本次结果 if (loadReqIdRef.current !== reqId) return; if (!res.success) { setError(res.error ?? '加载记忆失败'); return; } const data = res.data ?? {}; setMemories({ episodic: (data.episodic as MemoryItem[] | undefined) ?? [], semantic: (data.semantic as MemoryItem[] | undefined) ?? [], working: (data.working as MemoryItem[] | undefined) ?? [], }); } catch (err) { if (loadReqIdRef.current !== reqId) return; setError((err as Error).message ?? '加载记忆失败'); } }, []); // 初次挂载加载 useEffect(() => { loadMemories(); // cleanup: 使当前请求失效(防止卸载后 setState) return () => { loadReqIdRef.current++; }; }, [loadMemories]); // Agent 完成自动刷新 useEffect(() => { const prev = prevStatus.current; prevStatus.current = agentStatus; if ((prev === 'thinking' || prev === 'executing') && agentStatus === 'idle') { loadMemories(); } }, [agentStatus, loadMemories]); // v0.3.6: SettingsModal 清理记忆后 memoryVersion 自增,触发重新加载 // 之前清理后需重启应用 MemoryViewer 才会刷新(与 clearSessions 同样的 bug) useEffect(() => { if (memoryVersion > 0) { loadMemories(); } }, [memoryVersion, loadMemories]); const handleSearch = async () => { const q = searchQuery.trim(); if (!q || !window.metona?.memory?.search) return; setSearching(true); setError(null); // 审计补充修复: 使用独立 searchReqIdRef,不再与 loadMemories 共用 const reqId = ++searchReqIdRef.current; try { const results = await window.metona.memory.search(q, { topK: 20 }); // 竞态保护:若已被新搜索请求取代或组件已卸载,放弃本次结果 if (searchReqIdRef.current !== reqId) return; setSearchResults(results); } catch (err) { if (searchReqIdRef.current !== reqId) return; setError((err as Error).message ?? '搜索失败'); setSearchResults([]); } finally { // 审计补充修复: 无条件清理 searching 状态,避免被 loadMemories 取代时卡死 // searching 仅对当前搜索有意义,请求被取代后应停止 spinner setSearching(false); } }; const handleClearSearch = () => { setSearchQuery(''); setSearchResults(null); }; const handleDelete = async (type: MemoryType, id: string) => { if (!window.metona?.memory?.delete) return; try { const res = await window.metona.memory.delete(type, id); if (!res.success) { // 用户主动操作失败应用 toast(与项目惯例一致),不污染加载/搜索的 Alert import('metona-toast').then((mod) => mod.default.error(res.error ?? '删除失败')).catch(() => {}); return; } await loadMemories(); } catch (err) { import('metona-toast').then((mod) => mod.default.error((err as Error).message ?? '删除失败')).catch(() => {}); } }; const totalCount = memories.episodic.length + memories.semantic.length + memories.working.length; // 搜索结果视图 if (searchResults !== null) { return ( 记忆搜索 {searchResults.length} 条结果 setSearchQuery(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }} placeholder="搜索记忆..." sx={{ mb: 1.5, flexShrink: 0, '& .MuiOutlinedInput-root': { fontSize: 11, height: 30, borderRadius: 1.5 } }} slotProps={{ input: { startAdornment: ( ), endAdornment: searchQuery ? ( 清除 ) : null, }, }} /> {error && {error}} {searching ? ( 搜索中... ) : searchResults.length === 0 ? ( 无匹配结果 ) : ( searchResults.map((r) => ( )) )} ); } // 全量列表视图 return ( 记忆库 {totalCount} 条 setSearchQuery(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }} placeholder="搜索记忆..." sx={{ mb: 1.5, flexShrink: 0, '& .MuiOutlinedInput-root': { fontSize: 11, height: 30, borderRadius: 1.5 } }} slotProps={{ input: { startAdornment: ( ), }, }} /> {error && {error}} {totalCount === 0 ? ( 暂无记忆数据 ) : ( MEMORY_TYPES.map((type) => { const items = memories[type]; if (items.length === 0) return null; return ( setExpanded(isExpanded ? type : 'all')} elevation={0} sx={{ '&:before': { display: 'none' }, '& .MuiAccordionSummary-root': { minHeight: 32, px: 0 }, '& .MuiAccordionSummary-content': { my: 0, mx: 0 }, '& .MuiAccordionDetails-root': { px: 0, py: 0 }, }} > }> {MEMORY_TYPE_LABELS[type]} {items.map((item) => ( ))} ); }) )} ); } // ===== 子组件 ===== function MemoryItemRow({ item, onDelete, }: { item: MemoryItem; onDelete: (type: MemoryType, id: string) => void; }): React.JSX.Element { const type = item.type; const createdAt = getCreatedAt(item); const importance = item.importance ?? 0; return ( {typeof item.importance === 'number' && ( )} {createdAt > 0 && ( {formatTime(createdAt)} )} onDelete(type, getId(item))} sx={{ opacity: 0, transition: 'opacity 150ms', p: 0.25, '&:hover': { color: 'error.main' } }} > {truncate(item.content, 100)} ); } function MemorySearchItem({ item, onDelete, }: { item: SearchResult; onDelete: (type: MemoryType, id: string) => void; }): React.JSX.Element { return ( {item.createdAt > 0 && ( {formatTime(item.createdAt)} )} onDelete(item.type, item.id)} sx={{ opacity: 0, transition: 'opacity 150ms', p: 0.25, '&:hover': { color: 'error.main' } }} > {truncate(item.content, 100)} ); }