feat: v0.5.0 审计修复版 — 类型基线重建 + 会话隔离 + SubAgent 可观测性 + 三项功能补全
P0 安全与工程基线(止血): - .npmrc 移除硬编码 Gitea npm 凭据,改为 GITEA_NPM_AUTH 环境变量注入(已验证未设变量时 401) - 修复 typecheck 空操作缺陷:solution-style 根 tsconfig 改为双工程真检查(node + web), pre-commit 与 CI 门禁恢复拦截能力 - 修复 4 处 v0.4.1 遗留类型错误:confirmation-hook.test 枚举名 FILE_SYSTEM→FILESYSTEM、 agent.ts VALIDATION 事件 severity 类型谓词收窄、ContextMenu.tsx 导出 attachments 类型 - 补装 v0.4.1 声明但未安装的 node-html-parser 依赖 P1 逻辑缺陷修复(跨模块边界): - ConfirmationHook 会话隔离:rememberedDecisions 与 pendingConfirmations 按 sessionId 隔离, abortSession 只清本会话 pending(修复 A 会话中断误杀 B 会话确认、拒绝记忆跨会话污染) - SubAgent 可观测性:orchestrator 六个事件此前全项目零消费者,现接入 ① subagent:event 生命周期广播(AgentMonitor 新增 SubAgent 状态区) ② SubEngine 流事件独立 TRACE 录制(sessionId=taskId 的 JSONL 文件) - main.ts 启动链路异常兜底:初始化失败时记录日志 + 系统错误对话框 + 退出(原为白屏挂起) P2 工程强化: - CI:typecheck 双工程真检查;electron-test 从 experimental(continue-on-error)转正为阻塞门禁; GITEA_NPM_AUTH secret 注入说明 - 渲染 bundle 代码分割:单 2630KB chunk 拆为 main 557KB + vendor-react/mui/markdown/icons (业务代码变更不再使 vendor 缓存失效) - database 建表 mcp_servers CHECK 直接含 streamable-http(新库不再依赖迁移 6 立即重建) P3 功能补全: - DeepSeek 余额显示:新增 llm:getBalance IPC + LLMSettings 余额卡片(复用适配器原死代码 getBalance) - FTS5 会话内容搜索:messages_fts 虚表 + INSERT/UPDATE/DELETE 触发器实时同步 + 存量库 rebuild 迁移 + sessions:searchContent IPC + Sidebar 搜索框标题∪内容联合搜索 (短语转义防 FTS 运算符注入,按会话聚合展示 snippet) - 审计日志导出:audit:export IPC(JSONL / CSV RFC 4180 转义)+ LogsSettings 导出按钮 文档一致性大扫除: - README:工具数统一为 28(原 26/27/30 三口径)、handlers.ts→ipc/、录制事件名更正、 删除虚构的审计导出/归档宣称与 Schema 虚构字段、MCP 三种传输、配置 key 更正、 项目结构树对齐实际(settings 10 文件/lib 6 文件/react-virtuoso)、clone 地址改为 Gitea、 新增 GITEA_NPM_AUTH 配置说明、测试数 207 - 架构/构建指南/UI UX/IR 标准 4 份 HTML 设计文档同步修正(工具数、表数 10、 磁盘文件 2 个现状注记、ipc/*.ts 路径) - eslint.config.js 与开发规范.md 注释对齐零容忍基线与 better-sqlite3 选型 测试: 199→207 用例(新增 ConfirmationHook 跨会话隔离 5 用例 + FTS5 搜索/审计导出 8 用例) 验证: lint 0 problems / typecheck 双工程 0 errors / test:electron 207 全过 / build 成功
This commit is contained in:
@@ -5,9 +5,35 @@
|
||||
*/
|
||||
|
||||
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 {
|
||||
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 {
|
||||
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';
|
||||
@@ -21,34 +47,125 @@ export function Sidebar(): React.JSX.Element {
|
||||
const searchQuery = useSessionStore((s) => s.searchQuery);
|
||||
const setSearchQuery = useSessionStore((s) => s.setSearchQuery);
|
||||
const agentStatus = useAgentStore((s) => s.agentStatus);
|
||||
// v0.5.0: 内容搜索结果(FTS5),按会话聚合 { sessionId → snippet }
|
||||
const [contentMatches, setContentMatches] = useState<
|
||||
Map<string, { snippet: string; matchCount: number }>
|
||||
>(new Map());
|
||||
|
||||
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);
|
||||
});
|
||||
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; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// v0.5.0: 搜索词变化时防抖查询会话内容(FTS5 全文搜索,300ms 防抖减少 IPC 频率)
|
||||
useEffect(() => {
|
||||
const q = searchQuery.trim();
|
||||
if (!q || !window.metona?.sessions?.searchContent) {
|
||||
setContentMatches(new Map());
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(async () => {
|
||||
try {
|
||||
const r = await window.metona.sessions.searchContent(q);
|
||||
if (r.success && r.data) {
|
||||
const map = new Map<string, { snippet: string; matchCount: number }>();
|
||||
for (const item of r.data) {
|
||||
map.set(item.sessionId, { snippet: item.snippet, matchCount: item.matchCount });
|
||||
}
|
||||
setContentMatches(map);
|
||||
} else {
|
||||
setContentMatches(new Map());
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Sidebar] Content search failed:', err);
|
||||
setContentMatches(new Map());
|
||||
}
|
||||
}, 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [searchQuery]);
|
||||
|
||||
// L-13 修复: 使用 useMemo 缓存 filteredSessions,避免每次渲染都重建 Fuse 索引
|
||||
// v0.5.0: 标题匹配(Fuse 模糊)∪ 内容匹配(FTS5 精确短语),标题匹配优先排序
|
||||
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 list = sessions.filter((s) => !s.archived);
|
||||
if (searchQuery) {
|
||||
const fuse = new Fuse(list, { keys: ['title'], threshold: 0.4, ignoreLocation: true });
|
||||
const titleMatched = new Set(fuse.search(searchQuery).map((r) => r.item.id));
|
||||
// 内容匹配的会话并入结果(已含标题匹配的不重复)
|
||||
const merged = list.filter((s) => titleMatched.has(s.id) || contentMatches.has(s.id));
|
||||
// 标题匹配优先,内容匹配其次(各按置顶/更新时间排序)
|
||||
return merged.sort((a, b) => {
|
||||
const aTitle = titleMatched.has(a.id) ? 0 : 1;
|
||||
const bTitle = titleMatched.has(b.id) ? 0 : 1;
|
||||
if (aTitle !== bTitle) return aTitle - bTitle;
|
||||
return a.pinned === b.pinned ? b.updatedAt - a.updatedAt : a.pinned ? -1 : 1;
|
||||
});
|
||||
}
|
||||
return list.sort((a, b) =>
|
||||
a.pinned === b.pinned ? b.updatedAt - a.updatedAt : a.pinned ? -1 : 1,
|
||||
);
|
||||
}, [sessions, searchQuery, contentMatches]);
|
||||
|
||||
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 });
|
||||
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;
|
||||
@@ -56,22 +173,50 @@ export function Sidebar(): React.JSX.Element {
|
||||
// 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; // 不创建本地假会话
|
||||
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 };
|
||||
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
|
||||
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 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
size="small"
|
||||
onClick={handleNewSession}
|
||||
sx={{ mb: 1.5, fontSize: 12 }}
|
||||
>
|
||||
<Plus size={14} style={{ marginRight: 8 }} /> 新建会话
|
||||
</Button>
|
||||
|
||||
@@ -79,21 +224,44 @@ export function Sidebar(): React.JSX.Element {
|
||||
size="small"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="搜索会话..."
|
||||
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 } }}
|
||||
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); }} />
|
||||
))}
|
||||
<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')
|
||||
}
|
||||
contentMatch={contentMatches.get(session.id)}
|
||||
onClick={() => {
|
||||
setCurrentSession(session.id);
|
||||
loadSessionMessages(session.id);
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mt: 'auto', mb: 1 }} />
|
||||
@@ -103,7 +271,19 @@ export function Sidebar(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
function SessionItem({ session, isActive, isAgentActive, onClick }: { session: Session; isActive: boolean; isAgentActive: boolean; onClick: () => void }) {
|
||||
function SessionItem({
|
||||
session,
|
||||
isActive,
|
||||
isAgentActive,
|
||||
contentMatch,
|
||||
onClick,
|
||||
}: {
|
||||
session: Session;
|
||||
isActive: boolean;
|
||||
isAgentActive: boolean;
|
||||
contentMatch?: { snippet: string; matchCount: number };
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
const handleDelete = (e: React.MouseEvent) => {
|
||||
@@ -119,9 +299,11 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S
|
||||
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(() => {});
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => {
|
||||
mod.default.error('删除会话失败,请重试');
|
||||
})
|
||||
.catch(() => {});
|
||||
// 不调用 removeSession,保留会话在 UI 中(与数据库状态一致)
|
||||
setShowDeleteDialog(false);
|
||||
return;
|
||||
@@ -135,7 +317,13 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S
|
||||
currentSessionId: null,
|
||||
messages: [],
|
||||
traceSteps: [],
|
||||
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0 },
|
||||
tokenUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
lastInputTokens: 0,
|
||||
lastCompressedSaved: 0,
|
||||
},
|
||||
currentRunId: null,
|
||||
currentIteration: 0,
|
||||
});
|
||||
@@ -145,24 +333,101 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S
|
||||
|
||||
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 } }}>
|
||||
<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' }} />}
|
||||
{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>}
|
||||
<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>
|
||||
{/* v0.5.0: 内容匹配摘要(FTS5 snippet,高亮标记为 [匹配]) */}
|
||||
{contentMatch && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
display: 'block',
|
||||
fontSize: 10,
|
||||
color: 'text.disabled',
|
||||
mt: 0.25,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={contentMatch.snippet}
|
||||
>
|
||||
{contentMatch.matchCount > 1 ? `(${contentMatch.matchCount} 处) ` : ''}
|
||||
{contentMatch.snippet}
|
||||
</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 }}
|
||||
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>
|
||||
<Dialog
|
||||
open={showDeleteDialog}
|
||||
onClose={() => setShowDeleteDialog(false)}
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle sx={{ fontSize: 14 }}>删除会话</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
@@ -170,8 +435,16 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button size="small" onClick={() => setShowDeleteDialog(false)} sx={{ color: 'text.secondary' }}>取消</Button>
|
||||
<Button size="small" color="error" variant="contained" onClick={confirmDelete}>删除</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setShowDeleteDialog(false)}
|
||||
sx={{ color: 'text.secondary' }}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button size="small" color="error" variant="contained" onClick={confirmDelete}>
|
||||
删除
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
@@ -180,41 +453,129 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S
|
||||
|
||||
function ToolManagerPanel() {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [tools, setTools] = useState<Array<{ name: string; description: string; category: string; riskLevel: string; requiresPermission: boolean; enabled: boolean }>>([]);
|
||||
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(() => {});
|
||||
}
|
||||
});
|
||||
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; };
|
||||
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' };
|
||||
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
|
||||
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' } } }}>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user