/** * Sidebar — 左侧边栏 * * 始终显示,包含会话列表、新建会话按钮、搜索。 */ import { useState, useEffect } from 'react'; import { Box, Typography, Button, IconButton, TextField, Stack, 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, Database, 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(() => { if (window.metona?.sessions?.list) window.metona.sessions.list().then((list) => useSessionStore.getState().setSessions((list as any[]).map((s) => ({ id: s.id, title: s.title, createdAt: s.createdAt, updatedAt: s.updatedAt, messageCount: s.messageCount, pinned: s.pinned, archived: s.archived })))).catch(() => {}); }, []); const filteredSessions = (() => { 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); })(); 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 {} } 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 ( setSearchQuery(e.target.value)} placeholder="搜索会话..." slotProps={{ input: { startAdornment: , }, }} sx={{ mb: 1.5, '& .MuiOutlinedInput-root': { fontSize: 12, borderRadius: 1.5, height: 34 } }} /> {filteredSessions.length === 0 ? ( {searchQuery ? '无匹配结果' : '暂无会话'} ) : filteredSessions.map((session) => ( { setCurrentSession(session.id); loadSessionMessages(session.id); }} /> ))} ); } 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 = () => { window.metona?.sessions.delete(session.id).catch(() => {}); useSessionStore.getState().removeSession(session.id); setShowDeleteDialog(false); }; return ( <> {session.pinned ? : isAgentActive ? : } {session.title}} secondary={{formatRelativeTime(session.updatedAt)}{session.messageCount > 0 ? ` · ${session.messageCount} 条` : ''}} /> setShowDeleteDialog(false)} maxWidth="xs" fullWidth> 删除会话 确定删除会话「{session.title}」?此操作不可恢复。 ); } function ToolManagerPanel() { const [expanded, setExpanded] = useState(false); const tools = [ { name: 'read_file', label: '读取文件', risk: 'SAFE' }, { name: 'write_file', label: '写入文件', risk: 'MEDIUM' }, { name: 'list_directory', label: '列出目录', risk: 'SAFE' }, { name: 'search_files', label: '搜索文件', risk: 'SAFE' }, { name: 'web_search', label: '网络搜索', risk: 'LOW' }, { name: 'web_extract', label: '网页抓取', risk: 'LOW' }, { name: 'memory_store', label: '存储记忆', risk: 'MEDIUM' }, { name: 'memory_search', label: '搜索记忆', risk: 'SAFE' }, { name: 'run_command', label: '执行命令', risk: 'HIGH' }, ]; const riskColors: Record = { SAFE: 'success.main', LOW: 'info.main', MEDIUM: 'warning.main', HIGH: 'error.main' }; return ( setExpanded(!expanded)} dense sx={{ borderRadius: 1, px: 1.5, py: 0.75 }}> {expanded ? : } 工具管理} /> 9 就绪 {tools.map((t) => ( {t.label} {t.risk} ))} ); } function MemorySearchPanel() { const [expanded, setExpanded] = useState(false); const [query, setQuery] = useState(''); const [results, setResults] = useState>([]); const [searching, setSearching] = useState(false); const handleSearch = async () => { if (!query.trim() || !window.metona?.memory?.search) return; setSearching(true); try { setResults((await window.metona.memory.search(query, { topK: 5 })) as any); } catch { setResults([]); } setSearching(false); }; return ( setExpanded(!expanded)} dense sx={{ borderRadius: 1, px: 1.5, py: 0.75 }}> {expanded ? : } 记忆搜索} /> setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }} placeholder="搜索记忆..." sx={{ flex: 1, '& .MuiInputBase-root': { fontSize: 11, height: 28 } }} /> {results.length > 0 && ( {results.map((r) => ( {r.type} 重要度: {r.importance.toFixed(1)} {r.content.slice(0, 100)} ))} )} ); }