优化 Agent 过程展示,增加时间格式化和状态管理

This commit is contained in:
2026-05-13 17:43:06 +08:00
parent 536cd6a5d1
commit a4486e3d89
7 changed files with 249 additions and 41 deletions
+2 -1
View File
@@ -317,6 +317,7 @@ export const AgentHistoryPanel = ({
<Dialog <Dialog
open={isDeleteDialogOpen} open={isDeleteDialogOpen}
onClose={() => setIsDeleteDialogOpen(false)} onClose={() => setIsDeleteDialogOpen(false)}
sx={{ zIndex: (theme) => theme.zIndex.modal + 200 }}
TransitionProps={{ TransitionProps={{
onExited: () => setPendingDeleteSessionId(null) onExited: () => setPendingDeleteSessionId(null)
}} }}
@@ -346,7 +347,7 @@ export const AgentHistoryPanel = ({
> >
<WarningRounded sx={{ fontSize: 22 }} /> <WarningRounded sx={{ fontSize: 22 }} />
</Box> </Box>
<Typography variant="h6" fontWeight={800} color="text.primary"> <Typography component="span" variant="h6" fontWeight={800} color="text.primary">
</Typography> </Typography>
</DialogTitle> </DialogTitle>
@@ -5,13 +5,26 @@ import { AgentProgressTimeline } from "./AgentProgressTimeline";
import type { ChatProgress } from "./GlobalChatbox.types"; import type { ChatProgress } from "./GlobalChatbox.types";
describe("AgentProgressTimeline", () => { describe("AgentProgressTimeline", () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
});
afterEach(() => {
jest.useRealTimers();
});
it("shows the running step and keeps the timeline expanded while running", () => { it("shows the running step and keeps the timeline expanded while running", () => {
const now = Date.now();
const progress: ChatProgress[] = [ const progress: ChatProgress[] = [
{ {
id: "start", id: "start",
phase: "start", phase: "start",
status: "completed", status: "running",
title: "收到请求", title: "收到请求",
startedAt: now - 5000,
elapsedMs: 5000,
elapsedSnapshotAt: now,
}, },
{ {
id: "tool", id: "tool",
@@ -19,42 +32,79 @@ describe("AgentProgressTimeline", () => {
status: "running", status: "running",
title: "正在调用 dynamic_http_call", title: "正在调用 dynamic_http_call",
detail: "GET /api/v1/network/bottlenecks", detail: "GET /api/v1/network/bottlenecks",
startedAt: now - 1200,
elapsedMs: 1200,
elapsedSnapshotAt: now,
}, },
]; ];
render(<AgentProgressTimeline progress={progress} />); render(<AgentProgressTimeline progress={progress} />);
expect(screen.getByText("Agent 过程")).toBeInTheDocument(); expect(screen.getByText(/Agent 过程:/)).toBeInTheDocument();
expect(screen.getByText("正在调用 dynamic_http_call")).toBeInTheDocument(); expect(screen.getByText(/耗时 5.0s/)).toBeInTheDocument();
expect(screen.getByText("查询后端数据")).toBeInTheDocument(); expect(screen.getByText("查询后端数据")).toBeInTheDocument();
expect(screen.getByText("GET /api/v1/network/bottlenecks")).toBeInTheDocument(); expect(screen.getByText("GET /api/v1/network/bottlenecks")).toBeInTheDocument();
expect(screen.getByText("1.2s")).toBeInTheDocument();
}); });
it("summarizes completed steps and lets users expand details", async () => { it("summarizes completed steps and lets users expand details", async () => {
const progress: ChatProgress[] = [ const progress: ChatProgress[] = [
{ id: "start", phase: "start", status: "completed", title: "收到请求" }, {
{ id: "done", phase: "complete", status: "completed", title: "分析完成" }, id: "request-received",
phase: "start",
status: "completed",
title: "收到请求",
startedAt: Date.now() - 8000,
endedAt: Date.now(),
durationMs: 8000,
},
{
id: "done",
phase: "complete",
status: "completed",
title: "分析完成",
startedAt: Date.now() - 1000,
endedAt: Date.now(),
durationMs: 1000,
},
]; ];
render(<AgentProgressTimeline progress={progress} />); render(<AgentProgressTimeline progress={progress} />);
expect(screen.getByText("已完成 2 步")).toBeInTheDocument(); expect(screen.getByText(/已完成 \(2 步\)/)).toBeInTheDocument();
expect(screen.getByText(/耗时 8.0s/)).toBeInTheDocument();
expect(screen.queryByText("分析完成")).not.toBeVisible(); expect(screen.queryByText("分析完成")).not.toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "展开" })); fireEvent.click(screen.getByText(/Agent 过程:/));
expect(screen.getByText("分析完成")).toBeVisible(); expect(screen.getByText("分析完成")).toBeVisible();
}); });
it("treats stale running steps as finished after a complete event", () => { it("treats stale running steps as finished after a complete event", () => {
const progress: ChatProgress[] = [ const progress: ChatProgress[] = [
{ id: "tool", phase: "tool", status: "running", title: "正在调用 dynamic_http_call" }, {
{ id: "done", phase: "complete", status: "completed", title: "分析完成" }, id: "tool",
phase: "tool",
status: "completed",
title: "正在调用 dynamic_http_call",
startedAt: Date.now() - 4000,
endedAt: Date.now(),
},
{
id: "done",
phase: "complete",
status: "completed",
title: "分析完成",
startedAt: Date.now() - 500,
endedAt: Date.now(),
durationMs: 500,
},
]; ];
render(<AgentProgressTimeline progress={progress} />); render(<AgentProgressTimeline progress={progress} />);
expect(screen.getByText("已完成 2 步")).toBeInTheDocument(); expect(screen.getByText(/已完成 \(2 步\)/)).toBeInTheDocument();
expect(screen.getByText("4.0s")).toBeInTheDocument();
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
}); });
}); });
+93 -4
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { import {
Box, Box,
Collapse, Collapse,
@@ -22,6 +22,46 @@ import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRound
import type { ChatProgress } from "./GlobalChatbox.types"; import type { ChatProgress } from "./GlobalChatbox.types";
const formatDuration = (durationMs: number) => {
if (!Number.isFinite(durationMs) || durationMs < 0) {
return "0s";
}
if (durationMs < 10_000) {
return `${(durationMs / 1000).toFixed(1)}s`;
}
const totalSeconds = Math.round(durationMs / 1000);
if (totalSeconds < 60) {
return `${totalSeconds}s`;
}
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes < 60) {
return `${minutes}m ${seconds.toString().padStart(2, "0")}s`;
}
const hours = Math.floor(minutes / 60);
const remainMinutes = minutes % 60;
return `${hours}h ${remainMinutes.toString().padStart(2, "0")}m`;
};
const getProgressElapsedMs = (item: ChatProgress, nowMs: 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 + (nowMs - item.elapsedSnapshotAt));
}
if (item.startedAt !== undefined) {
return Math.max(0, nowMs - item.startedAt);
}
return item.elapsedMs;
}
if (item.startedAt !== undefined && item.endedAt !== undefined) {
return Math.max(0, item.endedAt - item.startedAt);
}
return item.elapsedMs;
};
const phaseIcon = (phase: string, status: ChatProgress["status"]) => { const phaseIcon = (phase: string, status: ChatProgress["status"]) => {
const sx = { fontSize: 16 }; const sx = { fontSize: 16 };
if (status === "completed") return <CheckCircleRounded sx={{ ...sx, color: "success.main" }} />; if (status === "completed") return <CheckCircleRounded sx={{ ...sx, color: "success.main" }} />;
@@ -46,6 +86,7 @@ const formatToolTitle = (item: ChatProgress) => {
export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatProgress[], isAborted?: boolean }) => { export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatProgress[], isAborted?: boolean }) => {
const theme = useTheme(); const theme = useTheme();
const [nowMs, setNowMs] = useState(() => Date.now());
// 判断是否最终完成(哪怕中间有报错,只要有完整的标记就算成功) // 判断是否最终完成(哪怕中间有报错,只要有完整的标记就算成功)
const isOverallComplete = progress.some( const isOverallComplete = progress.some(
@@ -56,6 +97,16 @@ export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatP
const hasRunning = !isAborted && !isOverallComplete && progress.some((item) => item.status === "running"); const hasRunning = !isAborted && !isOverallComplete && progress.some((item) => item.status === "running");
const hasError = isAborted || progress.some((item) => item.status === "error"); const hasError = isAborted || progress.some((item) => item.status === "error");
useEffect(() => {
if (!hasRunning) {
return;
}
const timer = window.setInterval(() => {
setNowMs(Date.now());
}, 500);
return () => window.clearInterval(timer);
}, [hasRunning]);
// 展开状态逻辑:默认折叠,保持界面整洁 // 展开状态逻辑:默认折叠,保持界面整洁
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
@@ -70,6 +121,31 @@ export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatP
return `已执行 ${progress.length}`; return `已执行 ${progress.length}`;
}, [isOverallComplete, hasError, progress, isAborted]); }, [isOverallComplete, hasError, progress, isAborted]);
const totalDurationLabel = useMemo(() => {
const requestProgress = progress.find((item) => item.id === "request-received");
const requestElapsed =
requestProgress ? getProgressElapsedMs(requestProgress, nowMs) : undefined;
if (requestElapsed !== undefined) {
return formatDuration(requestElapsed);
}
const startedAtValues = progress
.map((item) => item.startedAt)
.filter((value): value is number => value !== undefined);
if (startedAtValues.length === 0) {
return undefined;
}
const minStartedAt = Math.min(...startedAtValues);
const endedAtValues = progress
.map((item) => item.endedAt)
.filter((value): value is number => value !== undefined);
const endAnchor = isOverallComplete
? endedAtValues.length > 0
? Math.max(...endedAtValues)
: nowMs
: nowMs;
return formatDuration(Math.max(0, endAnchor - minStartedAt));
}, [isOverallComplete, nowMs, progress]);
// 根据整体状态决定顶部卡片的颜色主题 // 根据整体状态决定顶部卡片的颜色主题
const statusColor = isOverallComplete const statusColor = isOverallComplete
? "#4caf50" // Success Green ? "#4caf50" // Success Green
@@ -120,6 +196,7 @@ export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatP
<Typography variant="caption" fontWeight={700} color="text.primary" sx={{ flex: 1, letterSpacing: 0.3 }}> <Typography variant="caption" fontWeight={700} color="text.primary" sx={{ flex: 1, letterSpacing: 0.3 }}>
Agent : {summary} Agent : {summary}
{totalDurationLabel ? ` · 耗时 ${totalDurationLabel}` : ""}
</Typography> </Typography>
<KeyboardArrowDownRounded <KeyboardArrowDownRounded
@@ -159,6 +236,7 @@ export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatP
{progress.map((item, index) => { {progress.map((item, index) => {
const isLast = index === progress.length - 1; const isLast = index === progress.length - 1;
const isHiddenWhenCollapsed = isCollapsible && index < progress.length - visibleCount; const isHiddenWhenCollapsed = isCollapsible && index < progress.length - visibleCount;
const stepElapsedMs = getProgressElapsedMs(item, nowMs);
const itemColor = isAborted && isLast const itemColor = isAborted && isLast
? theme.palette.error.main ? theme.palette.error.main
@@ -219,9 +297,20 @@ export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatP
</Box> </Box>
</Box> </Box>
<Box sx={{ minWidth: 0, flex: 1, pb: isLast ? 0 : 2 }}> <Box sx={{ minWidth: 0, flex: 1, pb: isLast ? 0 : 2 }}>
<Typography variant="caption" color="text.primary" fontWeight={600} sx={{ fontSize: "0.75rem" }}> <Stack direction="row" spacing={1} alignItems="center" justifyContent="space-between">
{item.phase === "tool" ? formatToolTitle(item) : item.title} <Typography variant="caption" color="text.primary" fontWeight={600} sx={{ fontSize: "0.75rem" }}>
</Typography> {item.phase === "tool" ? formatToolTitle(item) : item.title}
</Typography>
{stepElapsedMs !== undefined ? (
<Typography
variant="caption"
color="text.secondary"
sx={{ fontSize: "0.68rem", fontFamily: "var(--font-mono, monospace)" }}
>
{formatDuration(stepElapsedMs)}
</Typography>
) : null}
</Stack>
{item.detail && ( {item.detail && (
<Collapse in={expanded || isLast} timeout="auto"> <Collapse in={expanded || isLast} timeout="auto">
+27 -22
View File
@@ -12,6 +12,7 @@ import {
IconButton, IconButton,
Paper, Paper,
Stack, Stack,
Tooltip,
Typography, Typography,
alpha, alpha,
useTheme, useTheme,
@@ -411,7 +412,7 @@ export const AgentTurn = React.memo(
</Stack> </Stack>
<AnimatePresence> <AnimatePresence>
{isHovered && !isErrorMessage && ( {isHovered && (
<motion.div <motion.div
initial={{ opacity: 0, scale: 0.9, y: 5 }} initial={{ opacity: 0, scale: 0.9, y: 5 }}
animate={{ opacity: 1, scale: 1, y: 0 }} animate={{ opacity: 1, scale: 1, y: 0 }}
@@ -432,27 +433,31 @@ export const AgentTurn = React.memo(
boxShadow: `0 4px 12px ${alpha("#000", 0.08)}`, boxShadow: `0 4px 12px ${alpha("#000", 0.08)}`,
}} }}
> >
<IconButton <Tooltip title="复制">
size="small" <IconButton
aria-label="复制" size="small"
onClick={() => { aria-label="复制"
navigator.clipboard.writeText(message.content); onClick={() => {
// Could add a toast here navigator.clipboard.writeText(message.content);
}} // Could add a toast here
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }} }}
> sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
<ContentCopyRounded sx={{ fontSize: 16 }} /> >
</IconButton> <ContentCopyRounded sx={{ fontSize: 16 }} />
<IconButton </IconButton>
size="small" </Tooltip>
aria-label="重新生成" <Tooltip title="重新生成">
onClick={() => { <IconButton
onRegenerate(); size="small"
}} aria-label="重新生成"
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }} onClick={() => {
> onRegenerate();
<RefreshRounded sx={{ fontSize: 16 }} /> }}
</IconButton> sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
>
<RefreshRounded sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
</Paper> </Paper>
</motion.div> </motion.div>
)} )}
@@ -4,6 +4,11 @@ export type ChatProgress = {
status: "running" | "completed" | "error"; status: "running" | "completed" | "error";
title: string; title: string;
detail?: string; detail?: string;
startedAt?: number;
endedAt?: number;
elapsedMs?: number;
elapsedSnapshotAt?: number;
durationMs?: number;
}; };
export type AgentArtifactKind = "chart" | "map" | "panel" | "tool"; export type AgentArtifactKind = "chart" | "map" | "panel" | "tool";
@@ -53,12 +53,39 @@ const upsertProgress = (
) => { ) => {
const next = [...(progress ?? [])]; const next = [...(progress ?? [])];
const index = next.findIndex((item) => item.id === event.id); const index = next.findIndex((item) => item.id === event.id);
const existing = index >= 0 ? next[index] : undefined;
const now = Date.now();
const startedAt = event.startedAt ?? existing?.startedAt;
const isRunning = event.status === "running";
const endedAt = isRunning ? undefined : event.endedAt ?? existing?.endedAt ?? now;
const elapsedMs = isRunning
? event.elapsedMs ??
existing?.elapsedMs ??
(startedAt !== undefined ? Math.max(0, now - startedAt) : undefined)
: undefined;
const elapsedSnapshotAt = isRunning
? event.elapsedMs !== undefined
? now
: existing?.elapsedSnapshotAt ?? now
: undefined;
const durationMs = !isRunning
? event.durationMs ??
existing?.durationMs ??
(startedAt !== undefined && endedAt !== undefined
? Math.max(0, endedAt - startedAt)
: undefined)
: undefined;
const nextItem: ChatProgress = { const nextItem: ChatProgress = {
id: event.id, id: event.id,
phase: event.phase, phase: event.phase,
status: event.status, status: event.status,
title: event.title, title: event.title,
detail: event.detail, detail: event.detail,
startedAt,
endedAt,
elapsedMs,
elapsedSnapshotAt,
durationMs,
}; };
if (index >= 0) { if (index >= 0) {
next[index] = nextItem; next[index] = nextItem;
@@ -69,9 +96,24 @@ const upsertProgress = (
}; };
const completeRunningProgress = (progress: ChatProgress[] | undefined) => const completeRunningProgress = (progress: ChatProgress[] | undefined) =>
progress?.map((item) => progress?.map((item) => {
item.status === "running" ? { ...item, status: "completed" as const } : item, if (item.status !== "running") {
); return item;
}
const endedAt = Date.now();
return {
...item,
status: "completed" as const,
endedAt,
elapsedMs: undefined,
elapsedSnapshotAt: undefined,
durationMs:
item.durationMs ??
(item.startedAt !== undefined
? Math.max(0, endedAt - item.startedAt)
: item.elapsedMs),
};
});
const createUserMessage = (content: string, branchRootId?: string): Message => { const createUserMessage = (content: string, branchRootId?: string): Message => {
const id = createId(); const id = createId();
+17 -1
View File
@@ -3,7 +3,7 @@ import { config } from "@config/config";
export type StreamEvent = export type StreamEvent =
| { type: "token"; sessionId: string; content: string } | { type: "token"; sessionId: string; content: string }
| { type: "done"; sessionId: string } | { type: "done"; sessionId: string; totalDurationMs?: number }
| { type: "session_title"; sessionId: string; title: string } | { type: "session_title"; sessionId: string; title: string }
| { | {
type: "progress"; type: "progress";
@@ -13,12 +13,17 @@ export type StreamEvent =
status: "running" | "completed" | "error"; status: "running" | "completed" | "error";
title: string; title: string;
detail?: string; detail?: string;
startedAt?: number;
endedAt?: number;
elapsedMs?: number;
durationMs?: number;
} }
| { | {
type: "error"; type: "error";
sessionId?: string; sessionId?: string;
message: string; message: string;
detail?: string; detail?: string;
totalDurationMs?: number;
} }
| { | {
type: "tool_call"; type: "tool_call";
@@ -162,6 +167,11 @@ export const streamAgentChat = async ({
phase?: string; phase?: string;
status?: "running" | "completed" | "error"; status?: "running" | "completed" | "error";
title?: string; title?: string;
started_at?: number;
ended_at?: number;
elapsed_ms?: number;
duration_ms?: number;
total_duration_ms?: number;
}; };
if (event === "token") { if (event === "token") {
onEvent({ onEvent({
@@ -178,11 +188,16 @@ export const streamAgentChat = async ({
status: parsed.status ?? "running", status: parsed.status ?? "running",
title: parsed.title ?? "正在处理", title: parsed.title ?? "正在处理",
detail: parsed.detail, detail: parsed.detail,
startedAt: parsed.started_at,
endedAt: parsed.ended_at,
elapsedMs: parsed.elapsed_ms,
durationMs: parsed.duration_ms,
}); });
} else if (event === "done") { } else if (event === "done") {
onEvent({ onEvent({
type: "done", type: "done",
sessionId: parsed.session_id ?? "", sessionId: parsed.session_id ?? "",
totalDurationMs: parsed.total_duration_ms,
}); });
} else if (event === "session_title") { } else if (event === "session_title") {
onEvent({ onEvent({
@@ -196,6 +211,7 @@ export const streamAgentChat = async ({
sessionId: parsed.session_id, sessionId: parsed.session_id,
message: parsed.message ?? "unknown error", message: parsed.message ?? "unknown error",
detail: parsed.detail, detail: parsed.detail,
totalDurationMs: parsed.total_duration_ms,
}); });
} else if (event === "tool_call") { } else if (event === "tool_call") {
onEvent({ onEvent({