From a4486e3d89e98a45ef265dbf6f7c1b1f590a957f Mon Sep 17 00:00:00 2001 From: Huarch Date: Wed, 13 May 2026 17:43:06 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=20Agent=20=E8=BF=87=E7=A8=8B?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=EF=BC=8C=E5=A2=9E=E5=8A=A0=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E5=8C=96=E5=92=8C=E7=8A=B6=E6=80=81=E7=AE=A1?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/AgentHistoryPanel.tsx | 3 +- .../chat/AgentProgressTimeline.test.tsx | 70 +++++++++++-- src/components/chat/AgentProgressTimeline.tsx | 97 ++++++++++++++++++- src/components/chat/AgentTurn.tsx | 49 +++++----- src/components/chat/GlobalChatbox.types.ts | 5 + .../chat/hooks/useAgentChatSession.ts | 48 ++++++++- src/lib/chatStream.ts | 18 +++- 7 files changed, 249 insertions(+), 41 deletions(-) diff --git a/src/components/chat/AgentHistoryPanel.tsx b/src/components/chat/AgentHistoryPanel.tsx index e29fdfd..21b8dde 100644 --- a/src/components/chat/AgentHistoryPanel.tsx +++ b/src/components/chat/AgentHistoryPanel.tsx @@ -317,6 +317,7 @@ export const AgentHistoryPanel = ({ setIsDeleteDialogOpen(false)} + sx={{ zIndex: (theme) => theme.zIndex.modal + 200 }} TransitionProps={{ onExited: () => setPendingDeleteSessionId(null) }} @@ -346,7 +347,7 @@ export const AgentHistoryPanel = ({ > - + 删除确认 diff --git a/src/components/chat/AgentProgressTimeline.test.tsx b/src/components/chat/AgentProgressTimeline.test.tsx index 60cf369..b1e7b54 100644 --- a/src/components/chat/AgentProgressTimeline.test.tsx +++ b/src/components/chat/AgentProgressTimeline.test.tsx @@ -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(); - 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(); - 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(); - expect(screen.getByText("已完成 2 步")).toBeInTheDocument(); + expect(screen.getByText(/已完成 \(2 步\)/)).toBeInTheDocument(); + expect(screen.getByText("4.0s")).toBeInTheDocument(); expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); }); }); diff --git a/src/components/chat/AgentProgressTimeline.tsx b/src/components/chat/AgentProgressTimeline.tsx index 7cbb327..1b69d68 100644 --- a/src/components/chat/AgentProgressTimeline.tsx +++ b/src/components/chat/AgentProgressTimeline.tsx @@ -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 ; @@ -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 Agent 过程: {summary} + {totalDurationLabel ? ` · 耗时 ${totalDurationLabel}` : ""} { 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 - - {item.phase === "tool" ? formatToolTitle(item) : item.title} - + + + {item.phase === "tool" ? formatToolTitle(item) : item.title} + + {stepElapsedMs !== undefined ? ( + + {formatDuration(stepElapsedMs)} + + ) : null} + {item.detail && ( diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx index 65cc1d9..8083954 100644 --- a/src/components/chat/AgentTurn.tsx +++ b/src/components/chat/AgentTurn.tsx @@ -12,6 +12,7 @@ import { IconButton, Paper, Stack, + Tooltip, Typography, alpha, useTheme, @@ -411,7 +412,7 @@ export const AgentTurn = React.memo( - {isHovered && !isErrorMessage && ( + {isHovered && ( - { - 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) } }} - > - - - { - onRegenerate(); - }} - sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }} - > - - + + { + 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) } }} + > + + + + + { + onRegenerate(); + }} + sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }} + > + + + )} diff --git a/src/components/chat/GlobalChatbox.types.ts b/src/components/chat/GlobalChatbox.types.ts index 35e75a9..4fae4b3 100644 --- a/src/components/chat/GlobalChatbox.types.ts +++ b/src/components/chat/GlobalChatbox.types.ts @@ -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"; diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index f4219a2..e614d57 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -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(); diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts index 2da5472..eec1856 100644 --- a/src/lib/chatStream.ts +++ b/src/lib/chatStream.ts @@ -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({