/** * 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'; // v0.7.3 P4-3: 文案出层(字典含注册副作用,须在 t() 使用前 import) import { t } from '@renderer/lib/i18n'; import '@renderer/lib/i18n-strings'; // ===== 类型 ===== 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']; // v0.7.3 P4-3: 类型标签渲染时求值(i18next 字典注册是异步的,模块顶层固化会拿到 key 本体) const MEMORY_TYPE_LABEL_KEYS: Record = { episodic: 'memory.type.episodic', semantic: 'memory.type.semantic', working: 'memory.type.working', }; const memoryTypeLabel = (type: MemoryType): string => t(MEMORY_TYPE_LABEL_KEYS[type]); 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); // v0.6.4 修复(折叠交互根治):单值 `MemoryType | 'all'` 无法同时表达 // "全展开"与"收起其一"——初始 'all' 时点击任一类型想收起,onChange(false) 会把 // 状态设回 'all',首次点击视觉无效、二次点击却收起其它组。改为显式集合模型, // 每个类型的展开态互不干扰,初始仍为全展开(保持原体验)。 const [expandedTypes, setExpandedTypes] = useState>( () => new Set(MEMORY_TYPES), ); // 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 ?? t('memory.loadFailed')); 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 ?? t('memory.loadFailed')); } }, []); // 初次挂载加载 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 ?? t('memory.loadFailed')); 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-team/metona-toast') .then((mod) => mod.default.error(res.error ?? t('memory.deleteFailed'))) .catch(() => {}); return; } await loadMemories(); } catch (err) { import('@metona-team/metona-toast') .then((mod) => mod.default.error((err as Error).message ?? t('memory.deleteFailed'))) .catch(() => {}); } }; const totalCount = memories.episodic.length + memories.semantic.length + memories.working.length; // 搜索结果视图 if (searchResults !== null) { return ( {t('memory.search.title')} {t('memory.results', { count: searchResults.length })} setSearchQuery(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }} placeholder={t('memory.search.placeholder')} sx={{ mb: 1.5, flexShrink: 0, '& .MuiOutlinedInput-root': { fontSize: 11, height: 30, borderRadius: 1.5 }, }} slotProps={{ input: { startAdornment: ( ), endAdornment: searchQuery ? ( {t('memory.search.clear')} ) : null, }, }} /> {error && ( {error} )} {searching ? ( {t('memory.searching')} ) : searchResults.length === 0 ? ( {t('memory.search.empty')} ) : ( searchResults.map((r) => ( )) )} ); } // 全量列表视图 return ( {t('memory.list.title')} {t('memory.total', { count: totalCount })} setSearchQuery(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }} placeholder={t('memory.search.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 ( setExpandedTypes((prev) => { const next = new Set(prev); if (isExpanded) { next.add(type); } else { next.delete(type); } return next; }) } elevation={0} sx={{ '&:before': { display: 'none' }, '& .MuiAccordionSummary-root': { minHeight: 32, px: 0 }, '& .MuiAccordionSummary-content': { my: 0, mx: 0 }, '& .MuiAccordionDetails-root': { px: 0, py: 0 }, }} > }> {memoryTypeLabel(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); 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)} ); }