feat(chat): 优化分析过程与结果展示
Generic Container CI/CD / test-build-publish (push) Successful in 2m25s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 2m25s

This commit is contained in:
2026-08-26 18:05:03 +08:00
parent fee4fc6ce1
commit 0dad61ff1f
13 changed files with 1049 additions and 292 deletions
@@ -0,0 +1,384 @@
"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>
);
};