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 全链路接入
225 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|