重构 Agent 聊天,支持分支管理与消息克隆

This commit is contained in:
2026-04-30 13:05:45 +08:00
parent e5ca9e24aa
commit 36d1a8d6ea
20 changed files with 1722 additions and 586 deletions
+192 -112
View File
@@ -3,8 +3,6 @@
import React, { useMemo, useState } from "react";
import {
Box,
Button,
Chip,
Collapse,
LinearProgress,
Stack,
@@ -20,6 +18,7 @@ import BuildCircleRounded from "@mui/icons-material/BuildCircleRounded";
import TaskAltRounded from "@mui/icons-material/TaskAltRounded";
import PsychologyRounded from "@mui/icons-material/PsychologyRounded";
import SyncRounded from "@mui/icons-material/SyncRounded";
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
import type { ChatProgress } from "./GlobalChatbox.types";
@@ -27,12 +26,12 @@ const phaseIcon = (phase: string, status: ChatProgress["status"]) => {
const sx = { fontSize: 16 };
if (status === "completed") return <CheckCircleRounded sx={{ ...sx, color: "success.main" }} />;
if (status === "error") return <ErrorOutlineRounded sx={{ ...sx, color: "error.main" }} />;
if (phase === "planning") return <PsychologyRounded sx={{ ...sx, color: "primary.main" }} />;
if (phase === "planning") return <PsychologyRounded sx={{ ...sx, color: "#00acc1" }} />;
if (phase === "tool") return <BuildCircleRounded sx={{ ...sx, color: "warning.main" }} />;
if (phase === "complete") return <TaskAltRounded sx={{ ...sx, color: "success.main" }} />;
if (phase === "session") return <SyncRounded sx={{ ...sx, color: "info.main" }} />;
if (phase === "start") return <ManageSearchRounded sx={{ ...sx, color: "primary.main" }} />;
return <AutoAwesome sx={{ ...sx, color: "primary.main" }} />;
if (phase === "start") return <ManageSearchRounded sx={{ ...sx, color: "#00acc1" }} />;
return <AutoAwesome sx={{ ...sx, color: "#00acc1" }} />;
};
const formatToolTitle = (item: ChatProgress) => {
@@ -45,143 +44,224 @@ const formatToolTitle = (item: ChatProgress) => {
return item.title;
};
export const AgentProgressTimeline = ({ progress }: { progress: ChatProgress[] }) => {
export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatProgress[], isAborted?: boolean }) => {
const theme = useTheme();
const hasComplete = progress.some(
// 判断是否最终完成(哪怕中间有报错,只要有完整的标记就算成功)
const isOverallComplete = progress.some(
(item) => item.phase === "complete" && item.status === "completed",
);
const hasRunning =
!hasComplete && progress.some((item) => item.status === "running");
const hasError = progress.some((item) => item.status === "error");
const [expanded, setExpanded] = useState(hasRunning);
// 修正状态判断:如果外部标记为中断,或者没有完成标记
const hasRunning = !isAborted && !isOverallComplete && progress.some((item) => item.status === "running");
const hasError = isAborted || progress.some((item) => item.status === "error");
// 展开状态逻辑:默认折叠,保持界面整洁
const [expanded, setExpanded] = useState(false);
const summary = useMemo(() => {
const completedCount = progress.filter((item) => item.status === "completed").length;
const runningItem = hasComplete
? undefined
: [...progress].reverse().find((item) => item.status === "running");
if (runningItem) return runningItem.title;
if (hasError) return "过程存在异常";
if (hasComplete) return `已完成 ${progress.length}`;
return `完成 ${completedCount || progress.length}`;
}, [hasComplete, hasError, progress]);
if (isAborted) return `已中断 (进行到第 ${progress.length} 步)`;
if (isOverallComplete) {
return hasError ? `已完成 (含 ${progress.length} 步探索)` : `已完成 (${progress.length} 步)`;
}
const runningItem = [...progress].reverse().find((item) => item.status === "running");
if (runningItem) return `${runningItem.title}...`;
if (hasError) return "过程异常,尝试恢复中...";
return `执行 ${progress.length}`;
}, [isOverallComplete, hasError, progress, isAborted]);
// 根据整体状态决定顶部卡片的颜色主题
const statusColor = isOverallComplete
? "#4caf50" // Success Green
: isAborted || (hasError && !hasRunning)
? theme.palette.error.main // Error Red
: "#00acc1"; // Primary Cyan
// 默认折叠:只显示最新的三条
const visibleCount = 3;
const isCollapsible = progress.length > visibleCount;
return (
<Box
sx={{
borderRadius: 3,
bgcolor: alpha(theme.palette.primary.main, 0.045),
border: `1px solid ${alpha(theme.palette.primary.main, 0.14)}`,
borderRadius: 4,
bgcolor: alpha(statusColor, 0.04),
border: `1px solid ${alpha(statusColor, 0.15)}`,
backdropFilter: "blur(12px)",
overflow: "hidden",
transition: "all 0.3s ease",
"&:hover": {
bgcolor: alpha(statusColor, 0.06),
borderColor: alpha(statusColor, 0.25),
}
}}
>
<Stack
direction="row"
spacing={1}
spacing={1.5}
alignItems="center"
sx={{ px: 1.5, py: 1.1 }}
onClick={() => setExpanded(!expanded)}
sx={{
px: 2,
py: 1.25,
cursor: "pointer",
userSelect: "none"
}}
>
<AutoAwesome sx={{ fontSize: 17, color: "primary.main" }} />
<Typography variant="caption" fontWeight={800} color="text.primary">
Agent
{isOverallComplete ? (
<TaskAltRounded sx={{ fontSize: 18, color: statusColor }} />
) : hasRunning ? (
<AutoAwesome sx={{ fontSize: 18, color: statusColor, animation: "spin 2s linear infinite", "@keyframes spin": { "0%": { transform: "rotate(0deg)" }, "100%": { transform: "rotate(360deg)" } } }} />
) : hasError ? (
<ErrorOutlineRounded sx={{ fontSize: 18, color: statusColor }} />
) : (
<AutoAwesome sx={{ fontSize: 18, color: statusColor }} />
)}
<Typography variant="caption" fontWeight={700} color="text.primary" sx={{ flex: 1, letterSpacing: 0.3 }}>
Agent : {summary}
</Typography>
<Chip
size="small"
label={summary}
color={hasError ? "error" : hasRunning ? "primary" : "success"}
variant="outlined"
sx={{ height: 22, fontSize: "0.68rem", maxWidth: 180 }}
<KeyboardArrowDownRounded
sx={{
fontSize: 20,
color: "text.secondary",
transform: expanded ? "rotate(180deg)" : "rotate(0deg)",
transition: "transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
}}
/>
<Box sx={{ flex: 1 }} />
<Button
size="small"
onClick={() => setExpanded((value) => !value)}
sx={{ minWidth: 0, px: 0.75, fontSize: "0.72rem" }}
>
{expanded ? "收起" : "展开"}
</Button>
</Stack>
{hasRunning ? <LinearProgress sx={{ height: 3 }} /> : null}
<Collapse in={expanded} timeout="auto">
<Stack spacing={1} sx={{ px: 1.5, pb: 1.35 }}>
{progress.map((item, index) => (
<Stack key={item.id} direction="row" spacing={1} alignItems="stretch">
<Box
sx={{
position: "relative",
width: 18,
display: "flex",
justifyContent: "center",
flexShrink: 0,
pt: 0.1,
}}
>
{index < progress.length - 1 ? (
<Box
aria-hidden
sx={{
position: "absolute",
top: 18,
bottom: -10,
left: "50%",
width: 2,
transform: "translateX(-50%)",
borderRadius: 99,
bgcolor: alpha(
item.status === "error"
? theme.palette.error.main
: theme.palette.primary.main,
item.status === "completed" ? 0.22 : 0.36,
),
}}
/>
) : null}
{hasRunning && !expanded ? (
<LinearProgress
sx={{
height: 2,
bgcolor: "transparent",
"& .MuiLinearProgress-bar": { bgcolor: statusColor }
}}
/>
) : null}
<Collapse in={expanded || hasRunning} timeout="auto" unmountOnExit={false}>
<Box>
{hasRunning ? (
<LinearProgress
sx={{
height: 1,
bgcolor: alpha(statusColor, 0.1),
"& .MuiLinearProgress-bar": { bgcolor: statusColor }
}}
/>
) : (
<Box sx={{ height: 1, bgcolor: alpha(statusColor, 0.1) }} />
)}
<Stack spacing={0} sx={{ px: 2, py: 1.5 }}>
{progress.map((item, index) => {
const isLast = index === progress.length - 1;
const isHiddenWhenCollapsed = isCollapsible && index < progress.length - visibleCount;
const itemColor = isAborted && isLast
? theme.palette.error.main
: item.status === "error"
? theme.palette.error.main
: item.status === "completed"
? "#4caf50"
: "#00acc1";
const content = (
<Stack key={item.id} direction="row" spacing={1.5} alignItems="stretch">
<Box
sx={{
position: "relative",
zIndex: 1,
width: 18,
height: 18,
borderRadius: "50%",
bgcolor: alpha("#fff", 0.92),
width: 20,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
pt: 0.3,
}}
>
{phaseIcon(
item.phase,
hasComplete && item.status === "running"
? "completed"
: item.status,
)}
</Box>
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="caption" color="text.primary" fontWeight={700}>
{item.phase === "tool" ? formatToolTitle(item) : item.title}
</Typography>
{item.detail ? (
<Typography
variant="caption"
component="pre"
color="text.secondary"
{!isLast ? (
<Box
aria-hidden
sx={{
position: "absolute",
top: 22,
bottom: -6,
left: "50%",
width: 2,
transform: "translateX(-50%)",
borderRadius: 2,
bgcolor: alpha(itemColor, item.status === "completed" ? 0.2 : 0.4),
}}
/>
) : null}
<Box
sx={{
display: "block",
mt: 0.25,
m: 0,
whiteSpace: "pre-wrap",
fontFamily: "inherit",
fontSize: "0.7rem",
position: "relative",
zIndex: 1,
width: 20,
height: 20,
borderRadius: "50%",
bgcolor: alpha(theme.palette.background.paper, 0.9),
boxShadow: `0 0 0 2px ${alpha(itemColor, 0.1)}`,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{item.detail}
{phaseIcon(
item.phase,
isAborted && isLast ? "error" :
isOverallComplete && item.status === "running"
? "completed"
: item.status,
)}
</Box>
</Box>
<Box sx={{ minWidth: 0, flex: 1, pb: isLast ? 0 : 2 }}>
<Typography variant="caption" color="text.primary" fontWeight={600} sx={{ fontSize: "0.75rem" }}>
{item.phase === "tool" ? formatToolTitle(item) : item.title}
</Typography>
) : null}
</Box>
</Stack>
))}
</Stack>
{item.detail && (
<Collapse in={expanded || isLast} timeout="auto">
<Typography
variant="caption"
component="div"
sx={{
mt: 0.5,
px: 1.25,
py: 0.75,
borderRadius: 2,
bgcolor: alpha(itemColor, 0.05),
border: `1px solid ${alpha(itemColor, 0.1)}`,
color: "text.secondary",
whiteSpace: "pre-wrap",
fontFamily: "var(--font-mono, monospace)",
fontSize: "0.7rem",
lineHeight: 1.5,
wordBreak: "break-all",
}}
>
{item.detail}
</Typography>
</Collapse>
)}
</Box>
</Stack>
);
if (isHiddenWhenCollapsed) {
return (
<Collapse key={item.id} in={expanded} timeout="auto" unmountOnExit={false}>
{content}
</Collapse>
);
}
return content;
})}
</Stack>
</Box>
</Collapse>
</Box>
);