优化 Agent 过程展示,增加时间格式化和状态管理
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user