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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user