269 lines
10 KiB
TypeScript
269 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import React, { useMemo, useState } from "react";
|
|
import {
|
|
Box,
|
|
Collapse,
|
|
LinearProgress,
|
|
Stack,
|
|
Typography,
|
|
alpha,
|
|
useTheme,
|
|
} from "@mui/material";
|
|
import AutoAwesome from "@mui/icons-material/AutoAwesome";
|
|
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
|
|
import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
|
|
import ManageSearchRounded from "@mui/icons-material/ManageSearchRounded";
|
|
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";
|
|
|
|
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: "#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: "#00acc1" }} />;
|
|
return <AutoAwesome sx={{ ...sx, color: "#00acc1" }} />;
|
|
};
|
|
|
|
const formatToolTitle = (item: ChatProgress) => {
|
|
const text = `${item.title} ${item.detail ?? ""}`;
|
|
if (text.includes("dynamic_http_call")) return "查询后端数据";
|
|
if (text.includes("show_chart")) return "生成图表";
|
|
if (text.includes("locate_features")) return "地图定位";
|
|
if (text.includes("view_history")) return "打开历史曲线";
|
|
if (text.includes("view_scada")) return "打开 SCADA 面板";
|
|
return item.title;
|
|
};
|
|
|
|
export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatProgress[], isAborted?: boolean }) => {
|
|
const theme = useTheme();
|
|
|
|
// 判断是否最终完成(哪怕中间有报错,只要有完整的标记就算成功)
|
|
const isOverallComplete = progress.some(
|
|
(item) => item.phase === "complete" && item.status === "completed",
|
|
);
|
|
|
|
// 修正状态判断:如果外部标记为中断,或者没有完成标记
|
|
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(() => {
|
|
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: 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.5}
|
|
alignItems="center"
|
|
onClick={() => setExpanded(!expanded)}
|
|
sx={{
|
|
px: 2,
|
|
py: 1.25,
|
|
cursor: "pointer",
|
|
userSelect: "none"
|
|
}}
|
|
>
|
|
{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>
|
|
|
|
<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)"
|
|
}}
|
|
/>
|
|
</Stack>
|
|
|
|
{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",
|
|
width: 20,
|
|
display: "flex",
|
|
justifyContent: "center",
|
|
flexShrink: 0,
|
|
pt: 0.3,
|
|
}}
|
|
>
|
|
{!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={{
|
|
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",
|
|
}}
|
|
>
|
|
{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>
|
|
|
|
{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>
|
|
);
|
|
};
|