优化 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
open={isDeleteDialogOpen}
onClose={() => setIsDeleteDialogOpen(false)}
sx={{ zIndex: (theme) => theme.zIndex.modal + 200 }}
TransitionProps={{
onExited: () => setPendingDeleteSessionId(null)
}}
@@ -346,7 +347,7 @@ export const AgentHistoryPanel = ({
>
<WarningRounded sx={{ fontSize: 22 }} />
</Box>
<Typography variant="h6" fontWeight={800} color="text.primary">
<Typography component="span" variant="h6" fontWeight={800} color="text.primary">
</Typography>
</DialogTitle>
@@ -5,13 +5,26 @@ import { AgentProgressTimeline } from "./AgentProgressTimeline";
import type { ChatProgress } from "./GlobalChatbox.types";
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", () => {
const now = Date.now();
const progress: ChatProgress[] = [
{
id: "start",
phase: "start",
status: "completed",
status: "running",
title: "收到请求",
startedAt: now - 5000,
elapsedMs: 5000,
elapsedSnapshotAt: now,
},
{
id: "tool",
@@ -19,42 +32,79 @@ describe("AgentProgressTimeline", () => {
status: "running",
title: "正在调用 dynamic_http_call",
detail: "GET /api/v1/network/bottlenecks",
startedAt: now - 1200,
elapsedMs: 1200,
elapsedSnapshotAt: now,
},
];
render(<AgentProgressTimeline progress={progress} />);
expect(screen.getByText("Agent 过程")).toBeInTheDocument();
expect(screen.getByText("正在调用 dynamic_http_call")).toBeInTheDocument();
expect(screen.getByText(/Agent 过程:/)).toBeInTheDocument();
expect(screen.getByText(/耗时 5.0s/)).toBeInTheDocument();
expect(screen.getByText("查询后端数据")).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 () => {
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} />);
expect(screen.getByText("已完成 2 步")).toBeInTheDocument();
expect(screen.getByText(/已完成 \(2 步\)/)).toBeInTheDocument();
expect(screen.getByText(/耗时 8.0s/)).toBeInTheDocument();
expect(screen.queryByText("分析完成")).not.toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "展开" }));
fireEvent.click(screen.getByText(/Agent 过程:/));
expect(screen.getByText("分析完成")).toBeVisible();
});
it("treats stale running steps as finished after a complete event", () => {
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} />);
expect(screen.getByText("已完成 2 步")).toBeInTheDocument();
expect(screen.getByText(/已完成 \(2 步\)/)).toBeInTheDocument();
expect(screen.getByText("4.0s")).toBeInTheDocument();
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
});
});
+93 -4
View File
@@ -1,6 +1,6 @@
"use client";
import React, { useMemo, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import {
Box,
Collapse,
@@ -22,6 +22,46 @@ import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRound
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 sx = { fontSize: 16 };
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 }) => {
const theme = useTheme();
const [nowMs, setNowMs] = useState(() => Date.now());
// 判断是否最终完成(哪怕中间有报错,只要有完整的标记就算成功)
const isOverallComplete = progress.some(
@@ -55,6 +96,16 @@ export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatP
// 修正状态判断:如果外部标记为中断,或者没有完成标记
const hasRunning = !isAborted && !isOverallComplete && progress.some((item) => item.status === "running");
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);
@@ -70,6 +121,31 @@ export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatP
return `已执行 ${progress.length}`;
}, [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
? "#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 }}>
Agent : {summary}
{totalDurationLabel ? ` · 耗时 ${totalDurationLabel}` : ""}
</Typography>
<KeyboardArrowDownRounded
@@ -159,6 +236,7 @@ export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatP
{progress.map((item, index) => {
const isLast = index === progress.length - 1;
const isHiddenWhenCollapsed = isCollapsible && index < progress.length - visibleCount;
const stepElapsedMs = getProgressElapsedMs(item, nowMs);
const itemColor = isAborted && isLast
? theme.palette.error.main
@@ -219,9 +297,20 @@ export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatP
</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>
<Stack direction="row" spacing={1} alignItems="center" justifyContent="space-between">
<Typography variant="caption" color="text.primary" fontWeight={600} sx={{ fontSize: "0.75rem" }}>
{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 && (
<Collapse in={expanded || isLast} timeout="auto">
+27 -22
View File
@@ -12,6 +12,7 @@ import {
IconButton,
Paper,
Stack,
Tooltip,
Typography,
alpha,
useTheme,
@@ -411,7 +412,7 @@ export const AgentTurn = React.memo(
</Stack>
<AnimatePresence>
{isHovered && !isErrorMessage && (
{isHovered && (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 5 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
@@ -432,27 +433,31 @@ export const AgentTurn = React.memo(
boxShadow: `0 4px 12px ${alpha("#000", 0.08)}`,
}}
>
<IconButton
size="small"
aria-label="复制"
onClick={() => {
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) } }}
>
<ContentCopyRounded sx={{ fontSize: 16 }} />
</IconButton>
<IconButton
size="small"
aria-label="重新生成"
onClick={() => {
onRegenerate();
}}
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
>
<RefreshRounded sx={{ fontSize: 16 }} />
</IconButton>
<Tooltip title="复制">
<IconButton
size="small"
aria-label="复制"
onClick={() => {
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) } }}
>
<ContentCopyRounded sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
<Tooltip title="重新生成">
<IconButton
size="small"
aria-label="重新生成"
onClick={() => {
onRegenerate();
}}
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
>
<RefreshRounded sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
</Paper>
</motion.div>
)}
@@ -4,6 +4,11 @@ export type ChatProgress = {
status: "running" | "completed" | "error";
title: string;
detail?: string;
startedAt?: number;
endedAt?: number;
elapsedMs?: number;
elapsedSnapshotAt?: number;
durationMs?: number;
};
export type AgentArtifactKind = "chart" | "map" | "panel" | "tool";
@@ -53,12 +53,39 @@ const upsertProgress = (
) => {
const next = [...(progress ?? [])];
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 = {
id: event.id,
phase: event.phase,
status: event.status,
title: event.title,
detail: event.detail,
startedAt,
endedAt,
elapsedMs,
elapsedSnapshotAt,
durationMs,
};
if (index >= 0) {
next[index] = nextItem;
@@ -69,9 +96,24 @@ const upsertProgress = (
};
const completeRunningProgress = (progress: ChatProgress[] | undefined) =>
progress?.map((item) =>
item.status === "running" ? { ...item, status: "completed" as const } : item,
);
progress?.map((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 id = createId();
+17 -1
View File
@@ -3,7 +3,7 @@ import { config } from "@config/config";
export type StreamEvent =
| { type: "token"; sessionId: string; content: string }
| { type: "done"; sessionId: string }
| { type: "done"; sessionId: string; totalDurationMs?: number }
| { type: "session_title"; sessionId: string; title: string }
| {
type: "progress";
@@ -13,12 +13,17 @@ export type StreamEvent =
status: "running" | "completed" | "error";
title: string;
detail?: string;
startedAt?: number;
endedAt?: number;
elapsedMs?: number;
durationMs?: number;
}
| {
type: "error";
sessionId?: string;
message: string;
detail?: string;
totalDurationMs?: number;
}
| {
type: "tool_call";
@@ -162,6 +167,11 @@ export const streamAgentChat = async ({
phase?: string;
status?: "running" | "completed" | "error";
title?: string;
started_at?: number;
ended_at?: number;
elapsed_ms?: number;
duration_ms?: number;
total_duration_ms?: number;
};
if (event === "token") {
onEvent({
@@ -178,11 +188,16 @@ export const streamAgentChat = async ({
status: parsed.status ?? "running",
title: parsed.title ?? "正在处理",
detail: parsed.detail,
startedAt: parsed.started_at,
endedAt: parsed.ended_at,
elapsedMs: parsed.elapsed_ms,
durationMs: parsed.duration_ms,
});
} else if (event === "done") {
onEvent({
type: "done",
sessionId: parsed.session_id ?? "",
totalDurationMs: parsed.total_duration_ms,
});
} else if (event === "session_title") {
onEvent({
@@ -196,6 +211,7 @@ export const streamAgentChat = async ({
sessionId: parsed.session_id,
message: parsed.message ?? "unknown error",
detail: parsed.detail,
totalDurationMs: parsed.total_duration_ms,
});
} else if (event === "tool_call") {
onEvent({