Files
metona-ai-desktop/src/components/layout/Sidebar.tsx
T
thzxx 2230bcec3f feat: v0.4.0 四阶段迭代 — 安全加固 + 工程基线 + 架构重构 + 双 Provider 扩展
P0 安全修复:
- API Key 加密存储(safeStorage 密钥链,版本化前缀,历史明文平滑兼容)
- 间接提示注入防护(SecurityScanHook 工具结果深扫描,网络工具脱敏/本地工具警示分级)
- error:report IPC 断链修复(渲染进程错误上报落 electron-log + 审计)
- abort 信号贯通工具层(run_command/dev-tools 子进程随会话中断终止)
- run_command 沙箱加固(cd 系统目录/敏感文件读取拦截 + chcp 前缀剥离防解析退化)
- .env 真实生效(dotenv 回退加载,应用内配置优先)

P1 工程基础:
- ESLint 9 flat config + 全部 34 条存量 warnings 清零(零容忍基线)
- 测试基线 118 用例 11 文件(token/文件防护/权限/沙箱/注入/命令/引擎/注册表/审计链/摘要分层)
- test:electron 双模式(ELECTRON_RUN_AS_NODE 跑 Electron ABI,SQLite 套件全执行)
- SessionRecorder 多会话隔离 + 9 种 TRACE 事件补全(含最终轮 iteration_end)
- Provider 故障转移(重试耗尽/不可重试一次性切换 fallback + 前端通知)
- MCP 真就绪(等待全部连接完成再广播 tools:ready)
- SLO/HealthChecker 真实接入(60s 巡检 + 托盘状态)
- CONFIG_DEFAULTS 单一来源(消除 SEED 双源漂移)

P2 架构升级:
- handlers.ts 1940 行拆分为 13 个 IPC 域模块(防重入注册 + 多窗口广播)
- AgentEngineManager 每会话独立引擎(LRU 30 + adapter 工厂隔离 abort 信号)
- TaskOrchestrator EngineProvider 改造 + abortByParent 联动中断 SubAgent
- 会话摘要分层上下文(session_summaries 滚动摘要 + 截断游标清理防因果污染)
- 消息编辑重发/重新生成(truncateAfter IPC + store 动作 + UI)
- Markdown 导出 / WebSearch 并行抓取(并发 3)/ 记忆 TF 缓存 / 版本构建期注入

P3 能力扩展:
- OpenAI Adapter(o 系列推理模型 reasoning_effort/max_completion_tokens)
- Anthropic Adapter(原生 Messages API:tool_use 块/角色合并/thinking budget/图片 base64/SSE 事件机)
- 设置页/Onboarding 六 Provider 全链路接入
2026-08-20 23:17:02 +08:00

225 lines
12 KiB
TypeScript

/**
* Sidebar — 左侧边栏
*
* 始终显示,包含会话列表、新建会话按钮、搜索。
*/
import { useState, useEffect, useMemo } from 'react';
import { Box, Typography, Button, IconButton, TextField, Collapse, List, ListItemButton, ListItemIcon, ListItemText, Badge, Divider, Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material';
import Fuse from 'fuse.js';
import { Plus, Search, MessageSquare, Pin, Wrench, ChevronDown, ChevronRight, Trash2 } from 'lucide-react';
import { useSessionStore, type Session } from '@renderer/stores/session-store';
import { useAgentStore } from '@renderer/stores/agent-store';
import { formatRelativeTime } from '@renderer/lib/formatters';
import { LAYOUT } from '@renderer/lib/constants';
export function Sidebar(): React.JSX.Element {
const sessions = useSessionStore((s) => s.sessions);
const currentSessionId = useSessionStore((s) => s.currentSessionId);
const setCurrentSession = useSessionStore((s) => s.setCurrentSession);
const loadSessionMessages = useAgentStore((s) => s.setCurrentSession);
const searchQuery = useSessionStore((s) => s.searchQuery);
const setSearchQuery = useSessionStore((s) => s.setSearchQuery);
const agentStatus = useAgentStore((s) => s.agentStatus);
useEffect(() => {
// M-27 修复: 添加 cancelled 标志防止组件卸载后 setState
let cancelled = false;
if (window.metona?.sessions?.list) {
window.metona.sessions.list().then((list) => {
if (cancelled) return;
useSessionStore.getState().setSessions((list as Array<{ id: string; title: string; createdAt: number; updatedAt: number; messageCount: number; pinned: boolean; archived: boolean }>).map((s) => ({ id: s.id, title: s.title, createdAt: s.createdAt, updatedAt: s.updatedAt, messageCount: s.messageCount, pinned: s.pinned, archived: s.archived })));
}).catch((err) => {
// M-11 修复: 记录错误而非静默吞掉,便于诊断
console.error('[Sidebar] Failed to load sessions:', err);
});
}
return () => { cancelled = true; };
}, []);
// L-13 修复: 使用 useMemo 缓存 filteredSessions,避免每次渲染都重建 Fuse 索引
const filteredSessions = useMemo(() => {
let list = sessions.filter((s) => !s.archived);
if (searchQuery) { const fuse = new Fuse(list, { keys: ['title'], threshold: 0.4, ignoreLocation: true }); list = fuse.search(searchQuery).map((r) => r.item); }
return list.sort((a, b) => a.pinned === b.pinned ? b.updatedAt - a.updatedAt : a.pinned ? -1 : 1);
}, [sessions, searchQuery]);
const handleNewSession = async () => {
if (window.metona?.sessions?.create) {
try {
const r = await window.metona.sessions.create() as { id: string; title: string; createdAt: number; updatedAt: number; messageCount: number; pinned: boolean; archived: boolean };
useSessionStore.getState().addSession({ id: r.id, title: r.title, createdAt: r.createdAt, updatedAt: r.updatedAt, messageCount: r.messageCount, pinned: r.pinned, archived: r.archived });
setCurrentSession(r.id);
loadSessionMessages(r.id);
return;
} catch (err) {
// M-9 修复: 显示错误提示而非静默吞错后创建本地假会话
// 之前的行为:catch 后继续创建本地 s_${Date.now()} 会话,但该会话在主进程不存在,下次刷新消失
console.error('[Sidebar] Failed to create session:', err);
import('@metona-team/metona-toast').then((mod) => {
mod.default.error('创建会话失败,请检查数据库状态');
}).catch(() => {});
return; // 不创建本地假会话
}
}
const newSession: Session = { id: `s_${Date.now()}`, title: '新会话', createdAt: Date.now(), updatedAt: Date.now(), messageCount: 0, pinned: false, archived: false };
useSessionStore.getState().addSession(newSession);
setCurrentSession(newSession.id);
loadSessionMessages(newSession.id);
};
return (
<Box component="aside" sx={{ flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', bgcolor: 'background.paper', borderRight: 1, borderColor: 'divider', width: LAYOUT.SIDEBAR_WIDTH }}>
<Box sx={{ p: 1.5, flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Button variant="outlined" fullWidth size="small" onClick={handleNewSession} sx={{ mb: 1.5, fontSize: 12 }}>
<Plus size={14} style={{ marginRight: 8 }} /> 新建会话
</Button>
<TextField
size="small"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索会话..."
slotProps={{
input: {
startAdornment: <Search size={14} style={{ color: '#8b8fa7', marginRight: 8 }} />,
},
}}
sx={{ mb: 1.5, '& .MuiOutlinedInput-root': { fontSize: 12, borderRadius: 1.5, height: 34 } }}
/>
<Box sx={{ flex: 1, overflowY: 'auto' }}>
{filteredSessions.length === 0 ? (
<Typography variant="caption" sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.secondary' }}>{searchQuery ? '无匹配结果' : '暂无会话'}</Typography>
) : filteredSessions.map((session) => (
<SessionItem key={session.id} session={session} isActive={session.id === currentSessionId} isAgentActive={session.id === currentSessionId && (agentStatus === 'thinking' || agentStatus === 'executing')} onClick={() => { setCurrentSession(session.id); loadSessionMessages(session.id); }} />
))}
</Box>
<Divider sx={{ mt: 'auto', mb: 1 }} />
<ToolManagerPanel />
</Box>
</Box>
);
}
function SessionItem({ session, isActive, isAgentActive, onClick }: { session: Session; isActive: boolean; isAgentActive: boolean; onClick: () => void }) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
setShowDeleteDialog(true);
};
const confirmDelete = async () => {
// M-10 修复: 乐观更新失败时回滚,避免 UI 与数据库状态不一致
// 之前行为:catch 静默吞错,但下一行已 removeSession,导致用户以为已删除实际未删
if (window.metona?.sessions?.delete) {
try {
await window.metona.sessions.delete(session.id);
} catch (err) {
console.error('[Sidebar] Failed to delete session:', err);
import('@metona-team/metona-toast').then((mod) => {
mod.default.error('删除会话失败,请重试');
}).catch(() => {});
// 不调用 removeSession,保留会话在 UI 中(与数据库状态一致)
setShowDeleteDialog(false);
return;
}
}
useSessionStore.getState().removeSession(session.id);
// 修复: 删除当前会话时同步清空 agent-store,否则 ChatPanel 和 DetailPanel 仍显示已删除会话的内容
// 与 SettingsModal LogsSettings 的 clearSessions 分支一致
if (useAgentStore.getState().currentSessionId === session.id) {
useAgentStore.setState({
currentSessionId: null,
messages: [],
traceSteps: [],
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0 },
currentRunId: null,
currentIteration: 0,
});
}
setShowDeleteDialog(false);
};
return (
<>
<ListItemButton onClick={onClick} selected={isActive} dense sx={{ borderRadius: 1.5, mb: 0.25, px: 1.5, py: 0.75, borderRight: isActive ? '2px solid' : '2px solid transparent', borderColor: isActive ? 'primary.main' : 'transparent', '&:hover .delete-btn': { opacity: 1 } }}>
<ListItemIcon sx={{ minWidth: 24 }}>
{session.pinned ? <Pin size={10} style={{ color: '#818cf8' }} /> : isAgentActive ? <Badge color="success" variant="dot" sx={{ '& .MuiBadge-dot': { animation: 'pulse 2s infinite', width: 8, height: 8 } }}><MessageSquare size={12} style={{ color: '#8b8fa7' }} /></Badge> : <MessageSquare size={12} style={{ color: '#8b8fa7' }} />}
</ListItemIcon>
<ListItemText primary={<Typography variant="body2" sx={{ fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: isActive ? 'text.primary' : 'text.secondary' }}>{session.title}</Typography>}
secondary={<Typography variant="caption" sx={{ fontSize: 10 }}>{formatRelativeTime(session.updatedAt)}{session.messageCount > 0 ? ` · ${session.messageCount} 条` : ''}</Typography>}
/>
<IconButton
className="delete-btn"
size="small"
onClick={handleDelete}
sx={{ opacity: 0, transition: 'opacity 150ms', color: 'text.disabled', '&:hover': { color: 'error.main' }, width: 20, height: 20 }}
>
<Trash2 size={12} />
</IconButton>
</ListItemButton>
<Dialog open={showDeleteDialog} onClose={() => setShowDeleteDialog(false)} maxWidth="xs" fullWidth>
<DialogTitle sx={{ fontSize: 14 }}>删除会话</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
确定删除会话「{session.title}」?此操作不可恢复。
</Typography>
</DialogContent>
<DialogActions>
<Button size="small" onClick={() => setShowDeleteDialog(false)} sx={{ color: 'text.secondary' }}>取消</Button>
<Button size="small" color="error" variant="contained" onClick={confirmDelete}>删除</Button>
</DialogActions>
</Dialog>
</>
);
}
function ToolManagerPanel() {
const [expanded, setExpanded] = useState(false);
const [tools, setTools] = useState<Array<{ name: string; description: string; category: string; riskLevel: string; requiresPermission: boolean; enabled: boolean }>>([]);
useEffect(() => {
// M-54 修复: 添加 cancelled 标志,防止组件卸载后 setState
let cancelled = false;
if (window.metona?.tools?.list) {
window.metona.tools.list().then((list) => {
if (!cancelled) setTools(list as MetonaToolInfo[]);
}).catch((err) => {
console.error('[Sidebar]', err);
if (!cancelled) {
import('@metona-team/metona-toast').then((mod) => mod.default.error('加载工具列表失败')).catch(() => {});
}
});
}
return () => { cancelled = true; };
}, []);
const readyCount = tools.filter((t) => t.enabled).length;
const riskColors: Record<string, string> = { safe: 'success.main', low: 'info.main', medium: 'warning.main', high: 'error.main' };
const riskLabels: Record<string, string> = { safe: 'SAFE', low: 'LOW', medium: 'MEDIUM', high: 'HIGH' };
return (
<Box>
<ListItemButton onClick={() => setExpanded(!expanded)} dense sx={{ borderRadius: 1, px: 1.5, py: 0.75 }}>
<ListItemIcon sx={{ minWidth: 24 }}>{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}<Wrench size={12} style={{ marginLeft: 4 }} /></ListItemIcon>
<ListItemText primary={<Typography variant="body2" sx={{ fontSize: 12 }}>工具管理</Typography>} />
<Typography variant="caption" sx={{ color: readyCount > 0 ? 'success.main' : 'text.disabled', fontSize: 10 }}>{readyCount} 就绪</Typography>
</ListItemButton>
<Collapse in={expanded}>
<List dense disablePadding sx={{ pl: 3, maxHeight: 200, overflowY: 'auto', pr: 0.5, '&::-webkit-scrollbar': { width: 6 }, '&::-webkit-scrollbar-track': { borderRadius: 3 }, '&::-webkit-scrollbar-thumb': { bgcolor: 'divider', borderRadius: 3, '&:hover': { bgcolor: 'action.hover' } } }}>
{tools.map((t) => (
<ListItemButton key={t.name} dense sx={{ py: 0.25, px: 1, borderRadius: 1 }}>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: t.enabled ? riskColors[t.riskLevel] ?? 'text.disabled' : 'text.disabled', mr: 1, flexShrink: 0 }} />
<Typography variant="caption" sx={{ flex: 1, fontSize: 11, color: t.enabled ? 'text.secondary' : 'text.disabled' }}>{t.name}</Typography>
<Typography variant="caption" sx={{ fontSize: 9, color: t.enabled ? (riskColors[t.riskLevel] ?? 'text.disabled') : 'text.disabled' }}>{riskLabels[t.riskLevel] ?? t.riskLevel}</Typography>
</ListItemButton>
))}
</List>
</Collapse>
</Box>
);
}