feat: 升级至 v0.2.1 — 流式渲染修复、安全增强、工具自动执行

流式渲染修复:
- runId 机制防止 abort 后旧流事件污染新 run
- run lock 防止并发 run 污染引擎状态
- abort race 提前退出工具执行等待
- TERMINATED 状态通过 stateChange 发射
- tool_call_delta 流式参数拼接 + pending 占位替换
- 首轮卡片创建路径统一,traceStep 按 ID 精确匹配
- compressed 事件转发为 toast 通知

安全增强:
- ConfirmationHook 支持持久化自动执行(跨会话)
- 设置面板新增自动执行工具管理 UI
- SandboxManager 双重安全校验 fail-closed
- 审计日志链式哈希防篡改
- PromptInjectionDefender 中文注入标记清理
- scanCode 28 模式 + base64/$() 检测
- validatePath realpathSync 防符号链接逃逸
- code-search 使用 execFile 防命令注入

新增工具:
- file_editor、code_search、task_manager、diff_viewer

其他:
- Agent Loop 加 PARSING/REFLECTING 状态 + 指数退避重试
- MemoryManager TF-IDF 语义检索
- run_command Windows 中文编码修复(chcp 65001)
- 版本号 0.2.0 → 0.2.1
This commit is contained in:
thzxx
2026-07-12 12:54:52 +08:00
parent 469f53b623
commit 3c5aea8fb7
41 changed files with 4972 additions and 423 deletions
+440
View File
@@ -0,0 +1,440 @@
/**
* 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 { 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<MemoryType, string> = {
episodic: '情景记忆',
semantic: '语义记忆',
working: '工作记忆',
};
const MEMORY_TYPE_COLORS: Record<MemoryType, string> = {
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<Record<MemoryType, MemoryItem[]>>({
episodic: [], semantic: [], working: [],
});
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [searching, setSearching] = useState(false);
const [expanded, setExpanded] = useState<MemoryType | 'all'>('all');
// Agent 完成时自动刷新:监听 agentStatus 从 thinking/executing -> idle
const agentStatus = useAgentStore((s) => s.agentStatus);
const prevStatus = useRef(agentStatus);
const loadMemories = useCallback(async () => {
if (!window.metona?.memory?.listAll) return;
try {
setError(null);
const res = await window.metona.memory.listAll();
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) {
setError((err as Error).message ?? '加载记忆失败');
}
}, []);
// 初次挂载加载
useEffect(() => {
loadMemories();
}, [loadMemories]);
// Agent 完成自动刷新
useEffect(() => {
const prev = prevStatus.current;
prevStatus.current = agentStatus;
if ((prev === 'thinking' || prev === 'executing') && agentStatus === 'idle') {
loadMemories();
}
}, [agentStatus, loadMemories]);
const handleSearch = async () => {
const q = searchQuery.trim();
if (!q || !window.metona?.memory?.search) return;
setSearching(true);
setError(null);
try {
const results = await window.metona.memory.search(q, { topK: 20 });
setSearchResults(results);
} catch (err) {
setError((err as Error).message ?? '搜索失败');
setSearchResults([]);
} finally {
setSearching(false);
}
};
const handleClearSearch = () => {
setSearchQuery('');
setSearchResults(null);
};
const handleDelete = async (type: MemoryType, id: string) => {
if (!window.metona?.memory?.delete) return;
try {
setError(null);
const res = await window.metona.memory.delete(type, id);
if (!res.success) {
setError(res.error ?? '删除失败');
return;
}
await loadMemories();
} catch (err) {
setError((err as Error).message ?? '删除失败');
}
};
const totalCount =
memories.episodic.length + memories.semantic.length + memories.working.length;
// 搜索结果视图
if (searchResults !== null) {
return (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
<Stack direction="row" spacing={1} sx={{ mb: 1.5, flexShrink: 0, alignItems: 'center' }}>
<Brain size={14} style={{ color: '#a855f7' }} />
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>
</Typography>
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.disabled', fontSize: 10 }}>
{searchResults.length}
</Typography>
</Stack>
<TextField
size="small"
fullWidth
value={searchQuery}
onChange={(e) => 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: (
<InputAdornment position="start">
<Search size={12} style={{ color: '#8b8fa7' }} />
</InputAdornment>
),
endAdornment: searchQuery ? (
<InputAdornment position="end" sx={{ cursor: 'pointer' }} onClick={handleClearSearch}>
<Typography variant="caption" sx={{ fontSize: 10, color: 'text.secondary' }}></Typography>
</InputAdornment>
) : null,
},
}}
/>
{error && <Alert severity="error" sx={{ mb: 1, py: 0.5, fontSize: 11, flexShrink: 0 }}>{error}</Alert>}
<Box sx={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 0.5, minHeight: 0 }}>
{searching ? (
<Typography variant="caption" sx={{ textAlign: 'center', py: 2, color: 'text.disabled' }}>
...
</Typography>
) : searchResults.length === 0 ? (
<Typography variant="caption" sx={{ textAlign: 'center', py: 2, color: 'text.disabled' }}>
</Typography>
) : (
searchResults.map((r) => (
<MemorySearchItem key={`${r.type}-${r.id}`} item={r} onDelete={handleDelete} />
))
)}
</Box>
</Box>
);
}
// 全量列表视图
return (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
<Stack direction="row" spacing={1} sx={{ mb: 1.5, flexShrink: 0, alignItems: 'center' }}>
<Brain size={14} style={{ color: '#a855f7' }} />
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>
</Typography>
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.disabled', fontSize: 10 }}>
{totalCount}
</Typography>
</Stack>
<TextField
size="small"
fullWidth
value={searchQuery}
onChange={(e) => 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: (
<InputAdornment position="start">
<Search size={12} style={{ color: '#8b8fa7' }} />
</InputAdornment>
),
},
}}
/>
{error && <Alert severity="error" sx={{ mb: 1, py: 0.5, fontSize: 11, flexShrink: 0 }}>{error}</Alert>}
<Box sx={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{totalCount === 0 ? (
<Typography variant="caption" sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.disabled' }}>
</Typography>
) : (
MEMORY_TYPES.map((type) => {
const items = memories[type];
if (items.length === 0) return null;
return (
<Accordion
key={type}
expanded={expanded === type || expanded === 'all'}
onChange={(_, isExpanded) => 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 },
}}
>
<AccordionSummary expandIcon={<ChevronDown size={14} />}>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', width: '100%' }}>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: MEMORY_TYPE_COLORS[type], flexShrink: 0 }} />
<Typography variant="caption" sx={{ fontWeight: 600, fontSize: 11, color: 'text.primary' }}>
{MEMORY_TYPE_LABELS[type]}
</Typography>
<Chip
label={items.length}
size="small"
sx={{
ml: 'auto', mr: 0.5, height: 16, fontSize: 10,
bgcolor: 'action.hover', color: 'text.secondary',
'& .MuiChip-label': { px: 0.5 },
}}
/>
</Stack>
</AccordionSummary>
<AccordionDetails>
<Stack spacing={0.5}>
{items.map((item) => (
<MemoryItemRow key={getId(item)} item={item} onDelete={handleDelete} />
))}
</Stack>
</AccordionDetails>
</Accordion>
);
})
)}
</Box>
</Box>
);
}
// ===== 子组件 =====
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 (
<Box
sx={{
px: 1, py: 0.75, borderRadius: 1,
bgcolor: 'background.default', border: '1px solid', borderColor: 'divider',
'&:hover .delete-btn': { opacity: 1 },
}}
>
<Stack direction="row" spacing={0.5} sx={{ mb: 0.25, alignItems: 'center' }}>
<Chip
label={MEMORY_TYPE_LABELS[type]}
size="small"
sx={{
height: 14, fontSize: 9,
bgcolor: MEMORY_TYPE_COLORS[type] + '22',
color: MEMORY_TYPE_COLORS[type],
'& .MuiChip-label': { px: 0.5 },
}}
/>
{typeof item.importance === 'number' && (
<Chip
label={`I ${(item.importance ?? 0).toFixed(2)}`}
size="small"
sx={{
height: 14, fontSize: 9,
bgcolor: importanceColor(item.importance) + '22',
color: importanceColor(item.importance),
'& .MuiChip-label': { px: 0.5, fontFamily: 'monospace' },
}}
/>
)}
{createdAt > 0 && (
<Typography
variant="caption"
sx={{ ml: 'auto', fontSize: 9, color: 'text.disabled', fontFamily: 'monospace' }}
>
{formatTime(createdAt)}
</Typography>
)}
<IconButton
className="delete-btn"
size="small"
onClick={() => onDelete(type, getId(item))}
sx={{ opacity: 0, transition: 'opacity 150ms', p: 0.25, '&:hover': { color: 'error.main' } }}
>
<Trash2 size={11} />
</IconButton>
</Stack>
<Typography variant="caption" sx={{ fontSize: 11, color: 'text.primary', display: 'block', lineHeight: 1.4, wordBreak: 'break-word' }}>
{truncate(item.content, 100)}
</Typography>
</Box>
);
}
function MemorySearchItem({
item,
onDelete,
}: {
item: SearchResult;
onDelete: (type: MemoryType, id: string) => void;
}): React.JSX.Element {
return (
<Box
sx={{
px: 1, py: 0.75, borderRadius: 1,
bgcolor: 'background.default', border: '1px solid', borderColor: 'divider',
'&:hover .delete-btn': { opacity: 1 },
}}
>
<Stack direction="row" spacing={0.5} sx={{ mb: 0.25, alignItems: 'center' }}>
<Chip
label={MEMORY_TYPE_LABELS[item.type]}
size="small"
sx={{
height: 14, fontSize: 9,
bgcolor: MEMORY_TYPE_COLORS[item.type] + '22',
color: MEMORY_TYPE_COLORS[item.type],
'& .MuiChip-label': { px: 0.5 },
}}
/>
<Chip
label={`R ${(item.relevanceScore ?? 0).toFixed(2)}`}
size="small"
sx={{
height: 14, fontSize: 9,
bgcolor: '#22d3ee22', color: '#22d3ee',
'& .MuiChip-label': { px: 0.5, fontFamily: 'monospace' },
}}
/>
<Chip
label={`I ${(item.importance ?? 0).toFixed(2)}`}
size="small"
sx={{
height: 14, fontSize: 9,
bgcolor: importanceColor(item.importance) + '22',
color: importanceColor(item.importance),
'& .MuiChip-label': { px: 0.5, fontFamily: 'monospace' },
}}
/>
{item.createdAt > 0 && (
<Typography variant="caption" sx={{ ml: 'auto', fontSize: 9, color: 'text.disabled', fontFamily: 'monospace' }}>
{formatTime(item.createdAt)}
</Typography>
)}
<IconButton
className="delete-btn"
size="small"
onClick={() => onDelete(item.type, item.id)}
sx={{ opacity: 0, transition: 'opacity 150ms', p: 0.25, '&:hover': { color: 'error.main' } }}
>
<Trash2 size={11} />
</IconButton>
</Stack>
<Typography variant="caption" sx={{ fontSize: 11, color: 'text.primary', display: 'block', lineHeight: 1.4, wordBreak: 'break-word' }}>
{truncate(item.content, 100)}
</Typography>
</Box>
);
}