385 lines
12 KiB
TypeScript
385 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useMemo, useState } from "react";
|
|
import {
|
|
Box,
|
|
Collapse,
|
|
IconButton,
|
|
LinearProgress,
|
|
Stack,
|
|
Typography,
|
|
alpha,
|
|
useMediaQuery,
|
|
useTheme,
|
|
} from "@mui/material";
|
|
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
|
|
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
|
|
import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
|
|
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
|
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
|
import RadioButtonUncheckedRounded from "@mui/icons-material/RadioButtonUncheckedRounded";
|
|
import StopCircleRounded from "@mui/icons-material/StopCircleRounded";
|
|
|
|
import type { AgentActivity, AgentActivityAction } from "@/lib/chatStream";
|
|
|
|
const activityAccent = "#0097a7";
|
|
|
|
type TimedActivityItem = {
|
|
status: "running" | "completed" | "error" | "cancelled";
|
|
startedAt: number;
|
|
endedAt?: number;
|
|
elapsedMs?: number;
|
|
elapsedSnapshotAt?: number;
|
|
durationMs?: number;
|
|
};
|
|
|
|
const formatDuration = (durationMs: number | undefined) => {
|
|
if (durationMs === undefined || !Number.isFinite(durationMs)) return undefined;
|
|
if (durationMs < 10_000) return `${(durationMs / 1000).toFixed(1)}s`;
|
|
const seconds = Math.round(durationMs / 1000);
|
|
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
};
|
|
|
|
const getElapsedMs = (
|
|
item: TimedActivityItem,
|
|
now: number,
|
|
) => {
|
|
if (item.durationMs !== undefined) return item.durationMs;
|
|
if (item.status === "running") {
|
|
if (item.elapsedMs !== undefined && item.elapsedSnapshotAt !== undefined) {
|
|
return Math.max(0, item.elapsedMs + now - item.elapsedSnapshotAt);
|
|
}
|
|
return Math.max(0, now - item.startedAt);
|
|
}
|
|
return item.endedAt ? Math.max(0, item.endedAt - item.startedAt) : undefined;
|
|
};
|
|
|
|
const StatusIcon = ({
|
|
status,
|
|
size = 18,
|
|
}: {
|
|
status: AgentActivity["status"];
|
|
size?: number;
|
|
}) => {
|
|
if (status === "completed") {
|
|
return <CheckCircleRounded color="success" sx={{ fontSize: size }} />;
|
|
}
|
|
if (status === "error") {
|
|
return <ErrorOutlineRounded color="error" sx={{ fontSize: size }} />;
|
|
}
|
|
if (status === "cancelled") {
|
|
return <StopCircleRounded color="disabled" sx={{ fontSize: size }} />;
|
|
}
|
|
return <AutoAwesomeRounded sx={{ fontSize: size, color: activityAccent }} />;
|
|
};
|
|
|
|
const ActionStatusIcon = ({ status }: { status: AgentActivityAction["status"] }) => {
|
|
if (status === "error") {
|
|
return <ErrorOutlineRounded sx={{ mt: "2px", fontSize: 14, color: "error.main" }} />;
|
|
}
|
|
if (status === "completed") {
|
|
return <CheckCircleRounded sx={{ mt: "2px", fontSize: 14, color: "success.main" }} />;
|
|
}
|
|
return (
|
|
<RadioButtonUncheckedRounded
|
|
sx={{ mt: "2px", fontSize: 14, color: activityAccent }}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const ActionRow = ({ action, now }: { action: AgentActivityAction; now: number }) => {
|
|
const elapsed = getElapsedMs(action, now);
|
|
return (
|
|
<Stack
|
|
direction="row"
|
|
spacing={1}
|
|
alignItems="flex-start"
|
|
sx={{ minWidth: 0, py: 0.55 }}
|
|
>
|
|
<ActionStatusIcon status={action.status} />
|
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
|
<Stack
|
|
direction={{ xs: "column", sm: "row" }}
|
|
spacing={{ xs: 0.2, sm: 1 }}
|
|
justifyContent="space-between"
|
|
>
|
|
<Typography variant="body2" fontWeight={650} sx={{ lineHeight: 1.45 }}>
|
|
{action.title}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>
|
|
{formatDuration(elapsed)}
|
|
</Typography>
|
|
</Stack>
|
|
{action.target ? (
|
|
<Typography
|
|
variant="caption"
|
|
color="text.secondary"
|
|
sx={{
|
|
display: "block",
|
|
mt: 0.2,
|
|
fontFamily: "monospace",
|
|
wordBreak: "break-word",
|
|
whiteSpace: "pre-wrap",
|
|
}}
|
|
>
|
|
{action.target}
|
|
</Typography>
|
|
) : null}
|
|
{action.error ? (
|
|
<Typography
|
|
variant="caption"
|
|
color="error.main"
|
|
sx={{ display: "block", mt: 0.2, wordBreak: "break-word" }}
|
|
>
|
|
{action.error}
|
|
</Typography>
|
|
) : null}
|
|
</Box>
|
|
</Stack>
|
|
);
|
|
};
|
|
|
|
export const AgentActivityTimeline = ({
|
|
activities,
|
|
}: {
|
|
activities: AgentActivity[];
|
|
}) => {
|
|
const theme = useTheme();
|
|
const reduceMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
|
|
const hasRunning = activities.some((activity) => activity.status === "running");
|
|
const hasError = activities.some((activity) => activity.status === "error");
|
|
const hasCancelled = activities.some((activity) => activity.status === "cancelled");
|
|
const [expanded, setExpanded] = useState(false);
|
|
const [now, setNow] = useState(() => Date.now());
|
|
|
|
useEffect(() => {
|
|
if (!hasRunning) return;
|
|
const timer = window.setInterval(() => setNow(Date.now()), 500);
|
|
return () => window.clearInterval(timer);
|
|
}, [hasRunning]);
|
|
|
|
const current = [...activities]
|
|
.reverse()
|
|
.find((activity) => activity.status === "running") ?? activities.at(-1);
|
|
const totalDuration = useMemo(() => {
|
|
if (!activities.length) return undefined;
|
|
const start = Math.min(...activities.map((activity) => activity.startedAt));
|
|
const end = hasRunning
|
|
? now
|
|
: Math.max(
|
|
...activities.map((activity) => activity.endedAt ?? activity.startedAt),
|
|
);
|
|
return formatDuration(Math.max(0, end - start));
|
|
}, [activities, hasRunning, now]);
|
|
const overallStatus: AgentActivity["status"] = hasRunning
|
|
? "running"
|
|
: hasError
|
|
? "error"
|
|
: hasCancelled
|
|
? "cancelled"
|
|
: "completed";
|
|
const statusLabel = {
|
|
running: "进行中",
|
|
completed: "已完成",
|
|
error: "失败",
|
|
cancelled: "已停止",
|
|
}[overallStatus];
|
|
const statusColor = {
|
|
running: activityAccent,
|
|
completed: theme.palette.success.main,
|
|
error: theme.palette.error.main,
|
|
cancelled: theme.palette.text.secondary,
|
|
}[overallStatus];
|
|
const summary = hasRunning
|
|
? (current?.title ?? "正在分析")
|
|
: hasError
|
|
? "分析未完成"
|
|
: hasCancelled
|
|
? "分析已停止"
|
|
: `已完成 ${activities.length} 个阶段`;
|
|
|
|
return (
|
|
<Box
|
|
sx={{
|
|
overflow: "hidden",
|
|
borderRadius: 3,
|
|
border: `1px solid ${alpha(activityAccent, 0.16)}`,
|
|
bgcolor: alpha(activityAccent, 0.035),
|
|
}}
|
|
>
|
|
<Stack
|
|
direction="row"
|
|
spacing={1}
|
|
alignItems="center"
|
|
sx={{ px: 1.4, py: 1.05 }}
|
|
>
|
|
<Box
|
|
sx={{
|
|
width: 28,
|
|
height: 28,
|
|
flex: "0 0 auto",
|
|
display: "grid",
|
|
placeItems: "center",
|
|
borderRadius: 2,
|
|
color: activityAccent,
|
|
bgcolor: alpha(activityAccent, 0.1),
|
|
}}
|
|
>
|
|
<AutoAwesomeRounded sx={{ fontSize: 17 }} />
|
|
</Box>
|
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
|
<Stack direction="row" spacing={0.75} alignItems="center">
|
|
<Typography variant="body2" fontWeight={750}>
|
|
分析过程
|
|
</Typography>
|
|
<Stack
|
|
component="span"
|
|
direction="row"
|
|
spacing={0.45}
|
|
alignItems="center"
|
|
sx={{ color: statusColor }}
|
|
>
|
|
<Box
|
|
component="span"
|
|
sx={{
|
|
width: 5,
|
|
height: 5,
|
|
borderRadius: "50%",
|
|
bgcolor: "currentColor",
|
|
}}
|
|
/>
|
|
<Typography
|
|
component="span"
|
|
variant="caption"
|
|
fontWeight={700}
|
|
color="inherit"
|
|
>
|
|
{statusLabel}
|
|
</Typography>
|
|
</Stack>
|
|
</Stack>
|
|
<Typography
|
|
variant="caption"
|
|
color="text.secondary"
|
|
noWrap
|
|
sx={{ display: "block", mt: 0.1 }}
|
|
>
|
|
{summary}
|
|
{totalDuration ? ` · ${totalDuration}` : ""}
|
|
</Typography>
|
|
</Box>
|
|
<IconButton
|
|
size="small"
|
|
aria-label={expanded ? "收起分析过程" : "展开分析过程"}
|
|
onClick={() => setExpanded((current) => !current)}
|
|
sx={{
|
|
width: 28,
|
|
height: 28,
|
|
flex: "0 0 auto",
|
|
color: "text.secondary",
|
|
bgcolor: alpha("#000", 0.035),
|
|
"&:hover": { bgcolor: alpha("#000", 0.07) },
|
|
}}
|
|
>
|
|
{expanded ? (
|
|
<KeyboardArrowUpRounded sx={{ fontSize: 18 }} />
|
|
) : (
|
|
<KeyboardArrowDownRounded sx={{ fontSize: 18 }} />
|
|
)}
|
|
</IconButton>
|
|
</Stack>
|
|
{hasRunning ? (
|
|
<LinearProgress
|
|
sx={{
|
|
height: 2,
|
|
bgcolor: alpha(activityAccent, 0.08),
|
|
"& .MuiLinearProgress-bar": { bgcolor: activityAccent },
|
|
}}
|
|
/>
|
|
) : null}
|
|
<Collapse in={expanded} timeout={reduceMotion ? 0 : 180}>
|
|
<Stack
|
|
spacing={1.1}
|
|
sx={{
|
|
px: 1.4,
|
|
py: 1.15,
|
|
}}
|
|
>
|
|
{activities.map((activity, index) => {
|
|
const elapsed = formatDuration(getElapsedMs(activity, now));
|
|
return (
|
|
<Stack
|
|
key={activity.id}
|
|
direction="row"
|
|
spacing={1}
|
|
sx={{
|
|
position: "relative",
|
|
minWidth: 0,
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
width: 18,
|
|
flex: "0 0 18px",
|
|
position: "relative",
|
|
pt: "2px",
|
|
}}
|
|
>
|
|
{index < activities.length - 1 ? (
|
|
<Box
|
|
sx={{
|
|
position: "absolute",
|
|
top: 19,
|
|
bottom: -14,
|
|
left: 8.5,
|
|
width: "1px",
|
|
bgcolor: alpha(activityAccent, 0.18),
|
|
}}
|
|
/>
|
|
) : null}
|
|
<StatusIcon status={activity.status} size={17} />
|
|
</Box>
|
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
|
<Stack direction="row" spacing={1} justifyContent="space-between">
|
|
<Typography variant="body2" fontWeight={700} sx={{ lineHeight: 1.45 }}>
|
|
{activity.title}
|
|
</Typography>
|
|
<Typography
|
|
variant="caption"
|
|
color="text.secondary"
|
|
sx={{ flexShrink: 0 }}
|
|
>
|
|
{elapsed}
|
|
</Typography>
|
|
</Stack>
|
|
<Typography
|
|
variant="caption"
|
|
color="text.secondary"
|
|
sx={{ display: "block", mt: 0.25, lineHeight: 1.5 }}
|
|
>
|
|
{activity.reason}
|
|
</Typography>
|
|
{activity.actions.length ? (
|
|
<Stack
|
|
spacing={0}
|
|
sx={{
|
|
mt: 0.6,
|
|
pl: 1,
|
|
borderLeft: `1px solid ${alpha(activityAccent, 0.2)}`,
|
|
}}
|
|
>
|
|
{activity.actions.map((action) => (
|
|
<ActionRow key={action.id} action={action} now={now} />
|
|
))}
|
|
</Stack>
|
|
) : null}
|
|
</Box>
|
|
</Stack>
|
|
);
|
|
})}
|
|
</Stack>
|
|
</Collapse>
|
|
</Box>
|
|
);
|
|
};
|