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
+221
View File
@@ -0,0 +1,221 @@
/**
* ConfirmationDialog — 工具执行确认对话框(v0.2.0 新增)
*
* 当 Agent 调用 requiresPermission=true 或 riskLevel>=HIGH 的工具时,
* 通过此对话框请求用户确认。
*
* 监听 IPC 事件 `tool:confirmationRequest`,显示工具详情,
* 用户确认/拒绝后通过 `tool:confirmationResponse` IPC 通道发送结果。
*/
import { useState, useEffect, useCallback } from 'react';
import {
Dialog, DialogTitle, DialogContent, DialogActions,
Button, Typography, Box, Chip, Alert, FormControlLabel, Checkbox,
Accordion, AccordionSummary, AccordionDetails,
} from '@mui/material';
import { ShieldAlert, ChevronDown } from 'lucide-react';
interface ConfirmationRequest {
toolCallId: string;
toolName: string;
args: Record<string, unknown>;
riskLevel: string;
reason: string;
}
const RISK_COLORS: Record<string, 'default' | 'success' | 'warning' | 'error' | 'info'> = {
safe: 'success',
low: 'info',
medium: 'warning',
high: 'error',
critical: 'error',
};
export function ConfirmationDialog(): React.JSX.Element | null {
const [request, setRequest] = useState<ConfirmationRequest | null>(null);
const [remember, setRemember] = useState(false);
const [autoExecute, setAutoExecute] = useState(false);
useEffect(() => {
// 监听来自主进程的确认请求(通过 preload 暴露的 metona.tool API
const cleanup = window.metona?.tool?.onConfirmationRequest((data: unknown) => {
setRequest(data as ConfirmationRequest);
setRemember(false);
setAutoExecute(false);
});
return cleanup;
}, []);
const handleRespond = useCallback((approved: boolean) => {
if (!request) return;
// 通过 preload 暴露的 metona.tool API 发送响应
// autoExecute 仅在批准时生效(拒绝时无需持久化)
window.metona?.tool?.sendConfirmationResponse({
toolCallId: request.toolCallId,
approved,
remember,
autoExecute: approved && autoExecute,
});
setRequest(null);
}, [request, remember, autoExecute]);
if (!request) return null;
const riskColor = RISK_COLORS[request.riskLevel] ?? 'default';
// 格式化参数显示
const formatArg = (key: string, value: unknown): string => {
if (typeof value === 'string') {
// 长字符串截断
return value.length > 200 ? value.slice(0, 200) + '...' : value;
}
if (value === null) return 'null';
if (value === undefined) return 'undefined';
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
};
return (
<Dialog
open={!!request}
onClose={() => handleRespond(false)}
maxWidth="sm"
fullWidth
slotProps={{
paper: {
sx: { bgcolor: 'background.paper' },
},
}}
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ShieldAlert size={20} color="var(--mui-palette-warning-main)" />
<Typography variant="h6" component="span"></Typography>
</DialogTitle>
<DialogContent>
<Alert severity={riskColor === 'error' ? 'error' : 'warning'} sx={{ mb: 2 }}>
{request.reason}
</Alert>
<Box sx={{ mb: 2, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip
label={`工具: ${request.toolName}`}
color="primary"
size="small"
/>
<Chip
label={`风险: ${request.riskLevel}`}
color={riskColor}
size="small"
/>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Agent
</Typography>
<Accordion defaultExpanded sx={{ bgcolor: 'background.default' }}>
<AccordionSummary expandIcon={<ChevronDown size={16} />}>
<Typography variant="caption" sx={{ fontWeight: 600 }}>
({Object.keys(request.args).length} )
</Typography>
</AccordionSummary>
<AccordionDetails>
<Box
component="pre"
sx={{
fontFamily: 'monospace',
fontSize: 11,
color: 'text.primary',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
margin: 0,
maxHeight: 300,
overflow: 'auto',
}}
>
{Object.entries(request.args).map(([key, value]) => (
<Box key={key} component="div" sx={{ mb: 1 }}>
<Typography
component="span"
variant="caption"
sx={{ color: 'info.main', fontWeight: 700 }}
>
{key}:
</Typography>
<Typography
component="span"
variant="caption"
sx={{ color: 'text.primary', ml: 1 }}
>
{formatArg(key, value)}
</Typography>
</Box>
))}
</Box>
</AccordionDetails>
</Accordion>
<FormControlLabel
control={
<Checkbox
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
size="small"
/>
}
label={
<Typography variant="caption" color="text.secondary">
</Typography>
}
sx={{ mt: 1 }}
/>
<FormControlLabel
control={
<Checkbox
checked={autoExecute}
onChange={(e) => {
setAutoExecute(e.target.checked);
// 勾选永久自动执行时,自动勾选会话内记忆(保持一致)
if (e.target.checked) setRemember(true);
}}
size="small"
/>
}
label={
<Typography variant="caption" color="text.secondary">
</Typography>
}
sx={{ mt: 0.5 }}
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button
onClick={() => handleRespond(false)}
color="error"
variant="outlined"
size="small"
>
</Button>
<Button
onClick={() => handleRespond(true)}
color="success"
variant="contained"
size="small"
autoFocus
>
</Button>
</DialogActions>
</Dialog>
);
}
+1 -1
View File
@@ -191,7 +191,7 @@ function extractTextContent(children: React.ReactNode): string {
if (!children) return '';
if (Array.isArray(children)) return children.map(extractTextContent).join('');
if (typeof children === 'object' && 'props' in children) {
return extractTextContent((children as React.ReactElement).props as React.ReactNode);
return extractTextContent((children as React.ReactElement).props.children as React.ReactNode);
}
return '';
}
+7 -1
View File
@@ -13,7 +13,13 @@ export function MessageList(): React.JSX.Element {
const isStreaming = useAgentStore((s) => s.isStreaming);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
// 流式输出时高频触发,使用 throttle 避免滚动卡顿
useEffect(() => {
const timer = setTimeout(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, 80);
return () => clearTimeout(timer);
}, [messages]);
if (messages.length === 0 && !isStreaming) {
return (
+53
View File
@@ -0,0 +1,53 @@
/**
* ErrorBoundary — 渲染错误边界
*
* 捕获子组件渲染时的同步异常,显示降级 UI 而非整个应用白屏。
*/
import { Component, type ReactNode } from 'react';
import { Box, Typography, Button } from '@mui/material';
import { AlertTriangle } from 'lucide-react';
interface Props {
children: ReactNode;
fallbackTitle?: string;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: { componentStack: string }): void {
console.error('[ErrorBoundary]', error, info.componentStack);
}
handleReset = (): void => {
this.setState({ hasError: false, error: null });
};
render(): ReactNode {
if (!this.state.hasError) return this.props.children;
return (
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 1, alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<AlertTriangle size={24} color="var(--mui-palette-error-main)" />
<Typography variant="caption" sx={{ fontWeight: 600 }}>
{this.props.fallbackTitle ?? '组件渲染失败'}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 10, textAlign: 'center', wordBreak: 'break-word', maxWidth: '80%' }}>
{this.state.error?.message ?? '未知错误'}
</Typography>
<Button size="small" variant="outlined" onClick={this.handleReset} sx={{ mt: 1, textTransform: 'none', fontSize: 11 }}>
</Button>
</Box>
);
}
}
+46 -5
View File
@@ -1,16 +1,25 @@
/**
* DetailPanel — 右侧详情面板
*
* 布局策略:TraceViewer flex:1 撑满,TokenUsage/AgentMonitor flexShrink:0 固定
* 通过 Tab 切换显示 Trace / Memory / Tasks
*/
import { Box } from '@mui/material';
import { useState } from 'react';
import { Box, Tabs, Tab } from '@mui/material';
import { Activity, Brain, ListChecks } from 'lucide-react';
import { TraceViewer } from '@renderer/components/trace/TraceViewer';
import { TokenUsage } from '@renderer/components/trace/TokenUsage';
import { AgentMonitor } from '@renderer/components/layout/AgentMonitor';
import { MemoryViewer } from '@renderer/components/memory/MemoryViewer';
import { TaskList } from '@renderer/components/tasks/TaskList';
import { ErrorBoundary } from '@renderer/components/common/ErrorBoundary';
import { LAYOUT } from '@renderer/lib/constants';
type DetailTab = 'trace' | 'memory' | 'tasks';
export function DetailPanel(): React.JSX.Element {
const [tab, setTab] = useState<DetailTab>('trace');
return (
<Box
component="aside"
@@ -19,10 +28,42 @@ export function DetailPanel(): React.JSX.Element {
bgcolor: 'background.paper', borderLeft: 1, borderColor: 'divider', width: LAYOUT.DETAIL_WIDTH,
}}
>
<Tabs
value={tab}
onChange={(_, v) => setTab(v as DetailTab)}
variant="fullWidth"
sx={{
minHeight: 32, height: 32, flexShrink: 0,
borderBottom: 1, borderColor: 'divider',
'& .MuiTab-root': { minHeight: 32, height: 32, fontSize: 11, textTransform: 'none', py: 0, px: 1 },
'& .MuiTabs-indicator': { height: 2 },
}}
>
<Tab icon={<Activity size={12} />} iconPosition="start" label="Trace" value="trace" />
<Tab icon={<Brain size={12} />} iconPosition="start" label="Memory" value="memory" />
<Tab icon={<ListChecks size={12} />} iconPosition="start" label="Tasks" value="tasks" />
</Tabs>
<Box sx={{ p: 1.5, flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
<TraceViewer />
<TokenUsage />
<AgentMonitor />
{tab === 'trace' && (
<ErrorBoundary fallbackTitle="Trace 渲染失败">
<>
<TraceViewer />
<TokenUsage />
<AgentMonitor />
</>
</ErrorBoundary>
)}
{tab === 'memory' && (
<ErrorBoundary fallbackTitle="Memory 渲染失败">
<MemoryViewer />
</ErrorBoundary>
)}
{tab === 'tasks' && (
<ErrorBoundary fallbackTitle="Tasks 渲染失败">
<TaskList />
</ErrorBoundary>
)}
</Box>
</Box>
);
+1 -39
View File
@@ -7,7 +7,7 @@
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 { 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';
@@ -78,7 +78,6 @@ export function Sidebar(): React.JSX.Element {
<Divider sx={{ mt: 'auto', mb: 1 }} />
<ToolManagerPanel />
<MemorySearchPanel />
</Box>
</Box>
);
@@ -169,40 +168,3 @@ function ToolManagerPanel() {
</Box>
);
}
function MemorySearchPanel() {
const [expanded, setExpanded] = useState(false);
const [query, setQuery] = useState('');
const [results, setResults] = useState<Array<{ id: string; type: string; content: string; importance: number }>>([]);
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 (
<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} />}<Database size={12} style={{ marginLeft: 4 }} /></ListItemIcon>
<ListItemText primary={<Typography variant="body2" sx={{ fontSize: 12 }}></Typography>} />
</ListItemButton>
<Collapse in={expanded}>
<Stack spacing={1} sx={{ pl: 3, pr: 1, pb: 1 }}>
<Stack direction="row" spacing={0.5}>
<TextField size="small" value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }} placeholder="搜索记忆..." sx={{ flex: 1, '& .MuiInputBase-root': { fontSize: 11, height: 28 } }} />
<Button variant="outlined" size="small" onClick={handleSearch} disabled={searching} sx={{ minWidth: 40, height: 28, fontSize: 10 }}>{searching ? '...' : '搜索'}</Button>
</Stack>
{results.length > 0 && (
<Box sx={{ maxHeight: 200, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{results.map((r) => (
<Box key={r.id} sx={{ px: 1, py: 0.75, borderRadius: 1, bgcolor: 'background.default', fontSize: 10, color: 'text.secondary' }}>
<Stack direction="row" spacing={0.5} sx={{ mb: 0.25, alignItems: 'center' }}>
<Box sx={{ px: 0.5, borderRadius: 0.5, bgcolor: 'action.hover', color: 'primary.main', fontSize: 9 }}>{r.type}</Box>
<Typography variant="caption" sx={{ fontSize: 9 }}>: {r.importance.toFixed(1)}</Typography>
</Stack>
<Typography variant="caption" sx={{ fontSize: 10, display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.content.slice(0, 100)}</Typography>
</Box>
))}
</Box>
)}
</Stack>
</Collapse>
</Box>
);
}
+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>
);
}
+95 -3
View File
@@ -211,11 +211,53 @@ function AgentSettings() {
}
function ToolsSettings() {
const [tools, setTools] = useState<Array<{ name: string; description: string; riskLevel: string; enabled: boolean }>>([]);
useEffect(() => { if (window.metona?.tools?.list) window.metona.tools.list().then((l) => setTools((l as MetonaToolInfo[]).map((t) => ({ name: t.name, description: t.description, riskLevel: t.riskLevel, enabled: t.enabled })))).catch((err) => { console.error('[SettingsModal]', err); }); }, []);
const handleToggle = (name: string, enabled: boolean) => { setTools((p) => p.map((t) => t.name === name ? { ...t, enabled } : t)); window.metona?.tools?.toggle(name, enabled).catch((err) => { console.error('[SettingsModal]', err); }); };
const [tools, setTools] = useState<Array<{ name: string; description: string; riskLevel: string; requiresPermission: boolean; enabled: boolean }>>([]);
const [autoExecList, setAutoExecList] = useState<string[]>([]);
const loadTools = () => {
if (window.metona?.tools?.list) {
window.metona.tools.list().then((l) => setTools((l as MetonaToolInfo[]).map((t) => ({
name: t.name, description: t.description, riskLevel: t.riskLevel,
requiresPermission: t.requiresPermission, enabled: t.enabled,
})))).catch((err) => { console.error('[SettingsModal]', err); });
}
};
const loadAutoExec = () => {
if (window.metona?.tool?.getAutoExecuteList) {
window.metona.tool.getAutoExecuteList().then((r) => {
if (r.success) setAutoExecList(r.data);
}).catch((err) => { console.error('[SettingsModal]', err); });
}
};
useEffect(() => { loadTools(); loadAutoExec(); }, []);
const handleToggle = (name: string, enabled: boolean) => {
setTools((p) => p.map((t) => t.name === name ? { ...t, enabled } : t));
window.metona?.tools?.toggle(name, enabled).catch((err) => { console.error('[SettingsModal]', err); });
};
// 设置/取消自动执行
const handleSetAutoExec = async (name: string, enabled: boolean) => {
if (!window.metona?.tool?.setAutoExecute) return;
const r = await window.metona.tool.setAutoExecute(name, enabled);
if (r.success) {
setAutoExecList((p) => enabled ? [...p, name] : p.filter((n) => n !== name));
}
};
const riskColors: Record<string, 'success' | 'info' | 'warning' | 'error'> = { safe: 'success', low: 'info', medium: 'warning', high: 'error' };
// 需要确认的工具(high/critical 或 requiresPermission
const needsConfirmTools = tools.filter((t) =>
t.riskLevel === 'high' || t.riskLevel === 'critical' || t.requiresPermission,
);
// 需要确认但未设为自动执行的工具
const pendingConfirmTools = needsConfirmTools.filter((t) => !autoExecList.includes(t.name));
// 已设为自动执行的工具详情
const autoExecToolDetails = autoExecList
.map((name) => tools.find((t) => t.name === name))
.filter((t): t is NonNullable<typeof t> => t !== undefined);
return (
<Stack spacing={2}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}></Typography>
@@ -231,6 +273,56 @@ function ToolsSettings() {
</Stack>
</Stack>
))}
<Divider sx={{ my: 1 }} />
{/* ===== 自动执行工具管理 ===== */}
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}></Typography>
<Chip label={`${autoExecList.length}`} size="small" color={autoExecList.length > 0 ? 'success' : 'default'} variant="outlined" sx={{ height: 18, fontSize: 10 }} />
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary', lineHeight: 1.5 }}>
</Typography>
{/* 已自动执行的工具列表 */}
{autoExecToolDetails.length === 0 ? (
<Typography variant="caption" sx={{ textAlign: 'center', py: 2, color: 'text.disabled', fontStyle: 'italic' }}>
</Typography>
) : (
autoExecToolDetails.map((t) => (
<Stack key={t.name} direction="row" sx={{ py: 1, px: 1.5, borderRadius: 1.5, bgcolor: 'success.main', opacity: 0.8, justifyContent: 'space-between', alignItems: 'center' }}>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', minWidth: 0, flex: 1 }}>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12 }}>{t.name}</Typography>
<Chip label="自动" size="small" color="success" sx={{ height: 16, fontSize: 9 }} />
</Stack>
<Button size="small" color="warning" variant="outlined" sx={{ fontSize: 10, minWidth: 70 }} onClick={() => handleSetAutoExec(t.name, false)}>
</Button>
</Stack>
))
)}
{/* 可设为自动执行的工具(需要确认但未设置) */}
{pendingConfirmTools.length > 0 && (
<>
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary', mt: 1 }}>
</Typography>
{pendingConfirmTools.map((t) => (
<Stack key={t.name} direction="row" sx={{ py: 1, px: 1.5, borderRadius: 1.5, bgcolor: 'secondary.main', border: '1px dashed', borderColor: 'divider', justifyContent: 'space-between', alignItems: 'center' }}>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', minWidth: 0, flex: 1 }}>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12 }}>{t.name}</Typography>
<Chip label={t.riskLevel.toUpperCase()} size="small" color={riskColors[t.riskLevel]} variant="outlined" sx={{ height: 16, fontSize: 9 }} />
</Stack>
<Button size="small" color="success" variant="contained" sx={{ fontSize: 10, minWidth: 70 }} onClick={() => handleSetAutoExec(t.name, true)}>
</Button>
</Stack>
))}
</>
)}
</Stack>
);
}
+378
View File
@@ -0,0 +1,378 @@
/**
* TaskList — 任务列表
*
* 显示当前会话的任务,支持新增、完成切换、删除、展开查看描述。
*/
import { useState, useEffect, useCallback } from 'react';
import {
Box, Typography, Stack, List, ListItem, ListItemIcon,
Checkbox, Chip, IconButton, TextField, Button, Select, MenuItem, Collapse,
} from '@mui/material';
import { ListChecks, Plus, Trash2, ChevronRight, Circle } from 'lucide-react';
import { useAgentStore } from '@renderer/stores/agent-store';
import { formatTime } from '@renderer/lib/formatters';
// ===== 类型 =====
type TaskStatus = MetonaTask['status'];
type TaskPriority = MetonaTask['priority'];
// ===== 常量 =====
const STATUS_LABELS: Record<TaskStatus, string> = {
pending: '待处理',
in_progress: '进行中',
completed: '已完成',
blocked: '阻塞',
cancelled: '已取消',
};
const STATUS_COLORS: Record<TaskStatus, string> = {
pending: '#f59e0b',
in_progress: '#a855f7',
completed: '#22c55e',
blocked: '#ef4444',
cancelled: '#64748b',
};
const PRIORITY_LABELS: Record<TaskPriority, string> = {
low: '低',
medium: '中',
high: '高',
critical: '紧急',
};
const PRIORITY_COLORS: Record<TaskPriority, string> = {
low: '#64748b',
medium: '#3b82f6',
high: '#f97316',
critical: '#ef4444',
};
const PRIORITY_OPTIONS: TaskPriority[] = ['low', 'medium', 'high', 'critical'];
// ===== 主组件 =====
export function TaskList(): React.JSX.Element {
const sessionId = useAgentStore((s) => s.currentSessionId);
const [tasks, setTasks] = useState<MetonaTask[]>([]);
const [error, setError] = useState<string | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
const [newTitle, setNewTitle] = useState('');
const [newPriority, setNewPriority] = useState<TaskPriority>('medium');
const [expandedId, setExpandedId] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const loadTasks = useCallback(async (sid?: string) => {
if (!window.metona?.tasks?.list) return;
try {
setError(null);
const res = await window.metona.tasks.list(sid);
if (!res.success) {
setError('加载任务失败');
return;
}
const list = (res.data ?? []).slice().sort((a, b) => {
if (a.order_idx !== b.order_idx) return a.order_idx - b.order_idx;
return a.created_at - b.created_at;
});
setTasks(list);
} catch (err) {
setError((err as Error).message ?? '加载任务失败');
}
}, []);
useEffect(() => {
loadTasks(sessionId ?? undefined);
}, [sessionId, loadTasks]);
const handleCreate = async () => {
if (!sessionId) {
setError('请先选择或创建会话');
return;
}
if (!newTitle.trim()) {
setError('任务标题不能为空');
return;
}
if (!window.metona?.tasks?.create) return;
setCreating(true);
setError(null);
try {
const res = await window.metona.tasks.create({
sessionId,
title: newTitle.trim(),
priority: newPriority,
});
if (!res.success) {
setError(res.error ?? '创建任务失败');
return;
}
setNewTitle('');
setNewPriority('medium');
setShowAddForm(false);
await loadTasks(sessionId);
} catch (err) {
setError((err as Error).message ?? '创建任务失败');
} finally {
setCreating(false);
}
};
const handleToggleComplete = async (task: MetonaTask) => {
if (!window.metona?.tasks?.update) return;
const nextStatus: TaskStatus = task.status === 'completed' ? 'pending' : 'completed';
setError(null);
try {
const res = await window.metona.tasks.update(task.id, { status: nextStatus });
if (!res.success) {
setError(res.error ?? '更新任务失败');
return;
}
await loadTasks(sessionId ?? undefined);
} catch (err) {
setError((err as Error).message ?? '更新任务失败');
}
};
const handleDelete = async (id: string) => {
if (!window.metona?.tasks?.delete) return;
setError(null);
try {
const res = await window.metona.tasks.delete(id);
if (!res.success) {
setError(res.error ?? '删除任务失败');
return;
}
if (expandedId === id) setExpandedId(null);
await loadTasks(sessionId ?? undefined);
} catch (err) {
setError((err as Error).message ?? '删除任务失败');
}
};
const toggleExpand = (id: string) => {
setExpandedId(expandedId === id ? null : id);
};
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' }}>
<ListChecks size={14} style={{ color: '#22d3ee' }} />
<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 }}>
{tasks.length}
</Typography>
<IconButton
size="small"
onClick={() => setShowAddForm(!showAddForm)}
sx={{ p: 0.25, color: 'primary.main', '&:hover': { bgcolor: 'action.hover' } }}
title="新增任务"
>
<Plus size={14} />
</IconButton>
</Stack>
{error && (
<Typography
variant="caption"
sx={{
mb: 1, px: 1, py: 0.5, fontSize: 10,
color: 'error.main', bgcolor: 'error.main' + '14',
borderRadius: 1, flexShrink: 0,
}}
>
{error}
</Typography>
)}
<Collapse in={showAddForm} sx={{ flexShrink: 0 }}>
<Box sx={{ mb: 1.5, p: 1, borderRadius: 1, bgcolor: 'background.default', border: '1px solid', borderColor: 'divider' }}>
<TextField
size="small"
fullWidth
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); }}
placeholder="任务标题..."
sx={{ mb: 1, '& .MuiOutlinedInput-root': { fontSize: 11, height: 30, borderRadius: 1 } }}
/>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Select
size="small"
value={newPriority}
onChange={(e) => setNewPriority(e.target.value as TaskPriority)}
sx={{ flex: 1, height: 28, fontSize: 11, '& .MuiSelect-select': { py: 0.5, px: 1 } }}
>
{PRIORITY_OPTIONS.map((p) => (
<MenuItem key={p} value={p} sx={{ fontSize: 11, py: 0.5 }}>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Circle size={8} style={{ color: PRIORITY_COLORS[p] }} />
<Typography variant="caption" sx={{ fontSize: 11 }}>{PRIORITY_LABELS[p]}</Typography>
</Stack>
</MenuItem>
))}
</Select>
<Button
variant="contained"
size="small"
onClick={handleCreate}
disabled={creating || !newTitle.trim()}
sx={{ height: 28, fontSize: 11, minWidth: 60, textTransform: 'none' }}
>
{creating ? '...' : '创建'}
</Button>
<IconButton
size="small"
onClick={() => { setShowAddForm(false); setNewTitle(''); setNewPriority('medium'); }}
sx={{ p: 0.5, color: 'text.secondary' }}
>
<Trash2 size={12} />
</IconButton>
</Stack>
</Box>
</Collapse>
<Box sx={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{!sessionId ? (
<Typography variant="caption" sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.disabled' }}>
</Typography>
) : tasks.length === 0 ? (
<Typography variant="caption" sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.disabled' }}>
+
</Typography>
) : (
<List disablePadding dense>
{tasks.map((task) => (
<TaskItemRow
key={task.id}
task={task}
expanded={expandedId === task.id}
onToggleComplete={() => handleToggleComplete(task)}
onToggleExpand={() => toggleExpand(task.id)}
onDelete={() => handleDelete(task.id)}
/>
))}
</List>
)}
</Box>
</Box>
);
}
// ===== 子组件 =====
function TaskItemRow({
task,
expanded,
onToggleComplete,
onToggleExpand,
onDelete,
}: {
task: MetonaTask;
expanded: boolean;
onToggleComplete: () => void;
onToggleExpand: () => void;
onDelete: () => void;
}): React.JSX.Element {
const isCompleted = task.status === 'completed';
const statusColor = STATUS_COLORS[task.status];
const priorityColor = PRIORITY_COLORS[task.priority];
return (
<ListItem
disableGutters
disablePadding
sx={{
display: 'block',
px: 0.5, py: 0.25,
borderRadius: 1,
'&:hover': { bgcolor: 'action.hover' },
'&:hover .delete-btn': { opacity: 1 },
}}
>
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'flex-start' }}>
<ListItemIcon sx={{ minWidth: 20, mt: 0.25 }}>
<Checkbox
size="small"
checked={isCompleted}
onChange={onToggleComplete}
sx={{ p: 0.25, '& .MuiSvgIcon-root': { fontSize: 14 } }}
/>
</ListItemIcon>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box
onClick={onToggleExpand}
sx={{
fontSize: 11, lineHeight: 1.3, cursor: 'pointer',
color: isCompleted ? 'text.disabled' : 'text.primary',
textDecoration: isCompleted ? 'line-through' : 'none',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}
>
{task.title}
</Box>
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'center', mt: 0.25, flexWrap: 'wrap' }}>
<Chip
label={STATUS_LABELS[task.status]}
size="small"
sx={{
height: 14, fontSize: 9,
bgcolor: statusColor + '22', color: statusColor,
'& .MuiChip-label': { px: 0.5 },
}}
/>
<Chip
label={PRIORITY_LABELS[task.priority]}
size="small"
sx={{
height: 14, fontSize: 9,
bgcolor: priorityColor + '22', color: priorityColor,
'& .MuiChip-label': { px: 0.5 },
}}
/>
<Typography
variant="caption"
component="span"
sx={{ fontSize: 9, color: 'text.disabled', fontFamily: 'monospace' }}
>
{formatTime(task.created_at)}
</Typography>
</Stack>
</Box>
<ChevronRight
size={12}
style={{
color: 'text.disabled',
transition: 'transform 150ms',
transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)',
cursor: 'pointer',
marginTop: 4,
}}
onClick={onToggleExpand}
/>
<IconButton
className="delete-btn"
size="small"
onClick={onDelete}
sx={{ opacity: 0, transition: 'opacity 150ms', p: 0.25, mt: 0.25, '&:hover': { color: 'error.main' } }}
>
<Trash2 size={11} />
</IconButton>
</Stack>
<Collapse in={expanded} timeout="auto" unmountOnExit>
<Box sx={{ pl: 3.5, pr: 1, py: 0.5, fontSize: 11, color: 'text.secondary', lineHeight: 1.4, wordBreak: 'break-word' }}>
{task.description ? task.description : (
<Typography variant="caption" sx={{ fontSize: 10, color: 'text.disabled', fontStyle: 'italic' }}>
</Typography>
)}
</Box>
</Collapse>
</ListItem>
);
}