重构 Agent 聊天,支持分支管理与消息克隆
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user